DEV Community

Cover image for Zero Trust at the Edge (part 3)
Tom Mount
Tom Mount

Posted on

Zero Trust at the Edge (part 3)

Working with JSON Web Key Sets

Scale up your JWT validation operation.

Recap

In Part 1 we looked at what a JSON Web Token is, how it's created, and how it's validated. In Part 2 we looked at a simple Express server with JWT validation middleware, with all the usual warnings about not reinventing the wheel.

Defining the Terms (mostly review)

As a popular language learning app says... Before we begin, here are some terms you should know:

  • JWT - short for JSON Web Token. This is the piece of information that needs to be validated (checked for authenticity) with every request.
  • Algorithm - in the JWT world, the algorithm is the combination of hashing and signing the token's payload. Comes in two flavors:
    • Symmetric - If you see HSxxx in the header, you're dealing with HMAC-SHAxxx signing algorithms. A secret key was used to sign the JWT, and that same secret key will be necessary to validate the signature later.
    • Asymmetric - If you see RSxxx, ESxxx, or PSxxx in the header, you're working with asymmetric signing algorithms. Of these, RSxxx is probably the most common and almost certainly the most performant; it uses RSA as the signing method, which requires a public/private keypair. The private key is used to sign the JWT, but it can be validated with the matching public key. ESxxx means Elliptic Curve Digital Signature Algorithm (ECDSA) was used; PSxxx also uses RSA under the hood but uses a newer signature scheme than the older RSxxx tokens use. If that doesn't make any sense whatsoever, that's fine - understanding the ins and outs of hashing and signing algorithms isn't strictly necessary to understand the basic workflow.
  • JWKS - short for JSON Web Key Set. If you don't know what that is, read on because that's the point of this post.
  • Public/Private Keypair - A set of two cryptographic certificates that are tied to each other. In (very) short, a private key can be manipulated to extract a public key, but the operation is one-way; that is, the public key cannot be used to generate the corresponding private key. Wikipedia has more information. Key pairs are commonly used to generate SSL certificates for websites, and for enabling passwordless SSH logins.

Scaling Up

In Part 2 we looked at an example repo that could validate either a symmetric algorithm or an asymmetric algorithm, based on the token's header's alg claim and either an environment variable or a public key. In Part 3 we'll consider ONLY asymmetric algorithms, specifically RS256.

One of the advantages of using a private/public keypair is that the verification can be done using a public key without the private key ever needing to leave the signing server. The public key is, after all, meant to be public, and because of the math involved, it's not possible to reverse-engineer the private key from just the public key. This means the public key can be loaded pretty much anywhere, which means the token can be validated pretty much anywhere too. But the downside of using a keypair is that if you rotate the public key every now and then (and you absolutely should do that) there will be some period, however brief, where the certificates won't match: the server will be signing tokens with the new private key, but the validation endpoints will still have the old public key, and the validator will fail. Building infrastructure around the update process can help mitigate the risk of a key mismatch but it's still not completely fail-proof.

JWKS represents a better way to manage your key rotation. When the JWT is signed by the issuing system, a key ID for the private key used to sign the token is stored in the header of the token. The validating system reads both the algorithm used as well as the key ID, and validates the signature based on the public key with the same ID. The JWKS is a serialized array containing one or more public keys with their IDs. The token header will contain just one more claim, the kid claim:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "12345"
}
Enter fullscreen mode Exit fullscreen mode

The kid claim can be any alphanumeric string - GUID, integer, name, they all work just as well, though care should be taken to ensure that the keys being used are not assigned the same ID.

The JWKS itself is a JSON object with a single keys array, which contains key objects:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "174EFCA3923B25163F581A0CB71CAD97B036E0B8",
      "x5t": "F078o5I7JRY_WBoMtxytl7A24Lg",
      "x5c": ["MIIEWzCCA0OgAwIBAgIJAPzw5pQEyIL4MA0GCSqGSIbgZ3..."],
      "e": "AQAB",
      "n": "n1x3isqbPYjG2dUm5d5N1MBk9zKHt5LujgFXJO1SCnCW...",
      "alg": "RS256"
    },
    { ... }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note that each key has a kid assigned; tokens should include this same kid in the header so that the public key can be loaded.

"But wait," you say, "where's the public key? What is this x5t and x5c and e and n stuff?" You're probably used to seeing public keys that look like this:

-----BEGIN PUBLIC KEY-----
(lots of letters and numbers)
(that go on for lines and lines)
-----END PUBLIC KEY-----
Enter fullscreen mode Exit fullscreen mode

What you see in the JWKS, though, is the raw information necessary to build the public key on the fly. Libraries like jsrsasign (which we used in Part 2) have methods to import the cryptographic information in the JWKS and construct the public key necessary to validate the token. Those libraries also have utilities to do the same thing in reverse - turn the more common certificate format (PEM) into a serialized data object.

Accessing the JWKS

While it's nice to have a serialized format that allows for the storage of multiple keys, it can still be a hassle to update this JWKS if you have to push it to multiple endpoints. A better strategy is to take advantage of autodiscovery and host the key set at a single URI at a well-known location so that consumers can periodically auto-update. For example, Microsoft Azure AD stores its JWKS at https://login.microsoftonline.com/common/discovery/v2.0/keys (you can visit that in your browser and see the JSON structure, though I'd recommend using something that can pretty-print the output as it's minified by default). When Microsoft wants to rotate a key, they simply push the new public key into the set and publish the new JWKS at the same URI. Consuming clients can poll the URI on a regular schedule and get the new public key. After a brief period (long enough that consuming clients have picked up the updated list) they will start signing tokens with the new private key, and any client that gets a new token will have the corresponding public key already loaded into memory.

Special Consideration: Generating the Keypair

Throughout this intro, I've mentioned public/private keypairs. You can generate these yourself, and if you've ever set up remote access to a server using SSH, you probably already have:

$ openssl genrsa -out jwt_signing_cert.pem 2048
$ openssl rsa -in jwt_signing_cert.pem -pubout -out jwt_public_key.pem
Enter fullscreen mode Exit fullscreen mode

This generates a 2048-bit private key using RSA, then extracts the public key and saves it. Once those two commands finish, you'll have two new .pem files, one the private key and the other the public key. You can absolutely use those files for signing and validation...but you probably shouldn't. The reason is that these keys are considered "self-signed" and using self-signed keys in public is generally considered a poor practice. Some cloud vendors and validation packages will not allow you to validate tokens using self-signed certificates.

When I first started using my former employer's JWKS solution, I was stymied by this restriction for a few days while trying to build a demo I could use in sales calls. Generating signed certificates generally costs money, and while I could have probably gotten a reimbursement for this, it was an expense I didn't want to incur. Ultimately, though, I realized that a PEM certificate is a PEM certificate, regardless of what it's used for, and there exists at least one service that was created for the sole purpose of issuing free certificates: Let's Encrypt. The process for doing that ended up being pretty straightforward, though it took a while to piece all the information together.

Before going down this route, you'll want to make sure that certbot is installed and up-to-date. Follow the official instructions if you're not sure.

You'll also need to make sure you have the means of generating your own cert with Let's Encrypt, which means you need access to the DNS settings for a domain.

It's important to note that by default, certbot will use ECDSA as the private key algorithm. It does still support using RSA keys (for now), but you will need to request it specifically. By default when creating RSA keys, certbot uses a 2048-bit key. While you can change this, you probably should not, as some cloud providers only support 2048-bit RSA keys.

To generate a signed 2048-bit RSA private key from Let's Encrypt, use the following command, either as root or using sudo:

$ certbot certonly \
  --manual \
  --preferred-challenges dns-01 \
  --key-type rsa \
  -d jwks01.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

I'm using the manual mode on certbot to specify my preferred challenge method (dns-01 which requires a DNS TXT record to prove ownership) and, critically, specifying the --key-type rsa flag to generate an RSA private key.

Certbot will give you a text record to place at _acme-challenge.jwks01.yourdomain.com (the actual subdomain you use is arbitrary; I chose jwks01 because it's unlikely I would actually host anything there, so I don't have to worry about naming collision, and because when I'm looking at all my _acme-challenge records I've collected over the years, I want to know what that one was for). Once that DNS record is in place and certbot has validated it, it will place four files in its default location; for me, that was /etc/letsencrypt/live/jwks01.yourdomain.com. DO NOT MOVE THESE FILES. We really only care about two of them: privkey.pem (the RSA private key) and fullchain.pem (the certificate signing chain). You'll want to copy those two files into your project directory.

(The other two files there are more relevant if you're setting up SSL for jwks01.yourdomain.com, which is the whole point of Let's Encrypt. However, as I mentioned earlier, a signed RSA private key is a signed RSA private key.)

You can use the commands above to generate the public key from the private key:

$ openssl rsa -in privkey.pem -pubout -out pubkey.pem
Enter fullscreen mode Exit fullscreen mode

And that's all there is to it! You're now the proud owner of a signed RSA private key and its corresponding public key.

"But wait," you say once again, "what about the fullchain.pem file?" Glad you asked! There's one more piece of this puzzle you need to generate an actual JWKS file, but because that's a bit more code-heavy, I'll save that for next week's installment.

Wrapping it Up

In this installment, we looked at what a JSON Web Key Set is and how it helps scale out the adoption of asymmetric RSA signing keys for JSON web tokens. We also took a brief detour into the world of public key cryptography and talked about ways to generate a valid public/private keypair that can be used to sign and validate JWTs.

The final installment will cover how to modify the existing express-jwt example repo introduced in Part 2 to import those keys, serve the JWKS at a well-known URI, and use the JWKS to validate multiple tokens signed by different private keys.

Further Reading

Top comments (0)