Incorporating JWKS into your JWT Validation Middleware
Build a full-featured signing and validating server.
Recap
In Parts 1 and 2, we looked at what a JWT is and built some Express middleware that we could use to validate JWTs. In Part 3, we learned about JSON Web Key Sets (JWKS) and considered how they allow us to sign JWTs with multiple private keys and rotate those private keys without having to worry much about any sort of mismatch when we validate those JWTs.
Important Warning ⚠️
In Part 2, recall that I said you should not roll your own validator from scratch because there are libraries already available that do this, and those libraries are maintained by people who are paid good money by large companies to maintain them. Once again, I warn you not to roll your own JWKS suite. Auth0 (not a sponsor, but, I mean, call me?) has a very serviceable free tier if you want to deploy a solution like this publicly. The code you're going to find in this post is really for illustration and learning ONLY and you should definitely not take it and simply deploy it somewhere and rely on it to provide adequate security.
Again, you have been warned.
Generating the JWKS
In Part 3 we looked at how to use Let's Encrypt and certbot
to generate a 2048-bit RSA private key, and how to extract the public key from that private key.
To start, we'll go into the express-jwt
project and add a dev dependencies on Cisco's node-jose:
$ npm install -D node-jose
We'll create a utility class to abstract away some of the more esoteric functions of the key store. Create a new file called src/JWKS.js
and paste in the following:
const jose = require('node-jose')
const crypto = require('node:crypto')
const fs = require('node:fs/promises')
class JWKS {
/** The underlying keystore. */
#store = jose.JWK.createKeyStore()
/** Whether or not this store can sign tokens. */
#canSign = false
constructor() {
//
}
/**
* Indicates whether the store is capable of signing a payload.
* @returns {boolean} True if the store can sign, false otherwise.
*/
get canSign() {
return this.#canSign
}
/**
* Add a certificate and chain to the store.
* @param {string} pathToKey The path to the key in PEM format. Can be a public or private key, but only private keys can sign requests.
* @param {string} pathToCertChain The path to the certificate chain in PEM format.
* @returns {Promise<jose.JWK.key>}
*/
async add(pathToKey, pathToCertChain) {
const key = await fs.readFile(pathToKey)
const chain = await fs.readFile(pathToCertChain)
const keyAsText = key.toString()
const chainAsText = chain.toString()
// If we're uploading a private key, or we already have, allow this instance to sign payloads.
this.#canSign = this.#canSign || keyAsText.startsWith('-----BEGIN PRIVATE KEY-----')
// Parse the chain for the required x5c values.
const begin = '-----BEGIN CERTIFICATE-----'
const end = '-----END CERTIFICATE-----'
const certsPem = chainAsText.split(end)
.map(cert => cert.trim())
.filter(cert => cert)
.map(cert => `${cert}${end}`)
const certsDer = certsPem.map(certPem =>
certPem.replace(begin, '')
.replace(end, '')
.replace(/[\n\r]/gm, '')
)
const thumbprints = certsDer.map(certDer => {
let sha1 = crypto.createHash('sha1')
sha1.update(certDer)
return sha1.digest('base64')
})
return this.#store.add(key, 'pem', {
use: 'sig',
alg: 'RS256',
x5c: certsDer,
x5t: thumbprints[0]
})
}
/**
* Remove a key from the store by its ID.
* @param {string} kid The Key ID to remove.
*/
remove(kid) {
const toRemove = this.#store.get(kid)
if (toRemove) this.#store.remove(toRemove)
// TODO: if we removed our only remaining private key, then we need to change #canSign.
}
/**
* The JWKS object suitable for pasting into a JWKS endpoint.
* @param {boolean} includePrivate Include private key information in the JWKS. NOT RECOMMENDED.
* @returns {any}
*/
asJWKS(includePrivate = false) {
return this.#store.toJSON(includePrivate)
}
/**
* Get the PEM-formatted public key for a given Key ID.
* @param {string} kid The Key ID to retrieve.
* @returns {string}
*/
publicKeyAsPem(kid) {
const key = this.#store.get(kid)
if (!key) {
throw new Error(`Key with ID ${kid} not found.`)
}
return key.toPEM(false)
}
}
module.exports = {
JWKS
}
As in Part 2, I made as many comments as I could to explain what is going on throughout the code, so I won't go into much detail here.
If you're wondering when I'm going to get to fullchain.pem
, it's up next. You'll notice the add
method references pathToKey
(that's the public or private key) and pathToCertChain
- that's the certificate chain for signed certs. You'll notice it's required, even if you're pushing up only a public key. This is by design - the x5c
portion of the JWKS assumes a certificate chain, even for public certs (eagle-eyed viewers will notice that it's an array, not a single string), and to generate a properly-formed JWKS, we need the chain.
We can test this out pretty easily by creating a quick test file and running it. Create a file called src/testJWKS.js
and drop in the following:
const { JWKS } = require("./JWKS.js");
(async () => {
const store = new JWKS()
// Add the private key and the fullchain certificate.
const addedKey = await store.add('../certs/jwks01.privkey.pem', '../certs/jwks01.fullchain.pem')
// Create a JWKS representation and dump it to the console.
const jwks = store.asJWKS()
console.log(JSON.stringify(jwks, null, 2))
console.log('\n*****\n')
// Using the key ID, retrieve the public key and dump it to the console.
const { kid } = addedKey
const publickey = store.publicKeyAsPem(kid)
console.log(publickey)
console.log('\n*****\n')
process.exit()
})()
If everything went well, two things should have been dumped:
- The JWKS as JSON, and
- The public key in PEM format.
So far so good!
Signing a JWT
Adding the functionality to sign a token is pretty simple with the underlying node-jose
library. Within the JWKS
class, add another method:
/**
* Sign a JWT.
* @param {any} payload The payload to sign.
* @returns {Promise<jose.JWS.createSignResult>} The signed JWT.
*/
async sign(payload) {
// If we haven't added a private key to the store, don't allow signing.
if (!this.#canSign) {
throw new Error('Unable to sign messages with only a public key.')
}
// The iss field isn't required but is a good idea.
if (!('iss' in payload)) {
payload.iss = 'ExampleJWKS'
}
const signer = jose.JWS.createSign({ format: 'compact'}, this.#store.get())
signer.update(JSON.stringify(payload))
return signer.final()
}
In the test file, we can add another block to create a payload and dump the signed JWT:
if (store.canSign) {
const payload = {
sub: '1234567890',
name: 'John Doe',
iat: Math.floor(Date.now() / 1000)
}
const jwt = await store.sign(payload)
console.log(jwt)
console.log('\n*****\n')
} else {
console.log('Cannot sign with a public key only. Please add a private key.')
}
Rerunning the test script, we now see a third piece of information dumped, something that looks remarkably like a JWT. If you copy that token and drop it into https://jwt.io you should see your full token:
Notice that the algorithm in the dropdown shows RS256
and there's a kid
claim in the header. You can also see that, without any public key information, the signature is invalid. The output of the test script dumped the public key; copy that and paste it into the first text box, and the page should update to indicate that the signature is valid.
But that was using a defined public key. What if we used the exported JWKS instead? The hint in the text box reads "Public Key in SPKI, PKCS #1, X.509 Certificate, or JWK string format." That last part is interesting - we're dumping the JWKS (remember the "S" stands for "set") but each entry in the keys
node is a single JWK. Go back to the console output and grab the first key only, ie. just the curly braces and what's inside them. Back on jwt.io, clear out the PEM certificate from the public key and instead paste in the JSON object. Was your signature verified?
What's handy about using node-jose
is that I didn't have to write any code to handle the key IDs, nor did I have to manually insert the ID. Because node-jose
knew the private key I'm using, it extracted a fingerprint as the key ID and included it automatically.
But what if I have multiple private keys? Fortunately, it's pretty easy to adjust the JWKS
class to handle multiple keys. Let's update the last bit of code in the sign
method:
// The iss field isn't required but is a good idea.
if (!('iss' in payload)) {
payload.iss = 'ExampleJWKS'
}
// Start editing here...
let signer
if (kid) {
signer = jose.JWS.createSign({ format: 'compact'}, this.#store.get(kid))
} else {
signer = jose.JWS.createSign({ format: 'compact'}, this.#store.get())
}
signer.update(JSON.stringify(payload))
return signer.final()
We're relying on the fact that get()
by itself will get the first valid signing key, while get(kid)
will get a specific key.
On the test script, we'll add a line under const addedKey...
:
const legacyKey = await store.add('../certs/jwks00.privkey.pem', '../certs/jwks00.fullchain.pem')
(where jwks00
refers to an older keypair from a different Let's Encrypt operation)
In the signing block, we'll extract the key ID for the legacy key and update the code to also create a JWT using that key:
const jwt = await store.sign(payload, kid)
const legacyKid = legacyKey.kid
const legacyJwt = await store.sign(payload, legacyKid)
console.log(jwt)
console.log('\n*****\n')
console.log(legacyJwt)
console.log('\n*****\n')
Now we're dumping the same payload twice, but each is signed by a different key. We can take the signed token into jwt.io and see that that kid
changes based on the token that we pasted. We can also paste in the corresponding JWK object from the dumped JWKS to see that only the matching kid
public key will validate the token.
This is all pretty cool stuff but it's still very manual. Let's go back to our express server and see if we can host the whole JWKS.
Hosting the JWKS
Before we start our server, we'll want to import the JWKS class, add our keys, and store the keystore in memory. Edit the src/index.js
file and add the following code underneath the last require
statement:
// JWKS handling
const { JWKS } = require('./JWKS.js')
const store = new JWKS()
/**
* Initialize the JWKS store with the certificates.
*/
async function initStore() {
const basepath = path.join(__dirname, '..', 'certs')
await store.add(`${basepath}/jwks00.privkey.pem`, `${basepath}/jwks00.fullchain.pem`)
await store.add(`${basepath}/jwks01.privkey.pem`, `${basepath}/jwks01.fullchain.pem`)
console.log('JWKS loaded.')
}
initStore().catch(err => {
console.log(err)
})
This will add the certs once the server starts and notify the console when complete. Finally, add another route for this information:
app.get('/.well-known/openid-configuration', (req, res) => {
const jwks = store.asJWKS()
res.json(jwks)
})
The URI path here reflects the same path that Auth0 uses for its autodiscovery service.
In a browser or on a command line, issue a GET request to http://localhost:3000/.well-known/openid-configuration
- the complete JWKS should be displayed.
Reading the JWKS and Validating the JWT
The Express application can already handle RS256 tokens, so there's not a huge amount of work needed to update it to handle specific key IDs passed in the token header. At a high level, though, we need to address a few things:
- We need to be able to access the JWKS store object within the middleware.
- We need to fetch a specific key from the store and present it to the
jsrsasign
library in a way it understands.
The first point can be handled in a variety of different ways, but in this case, we can either fetch it from the /.well-known/openid-configuration
path, or we can fetch it from the in-memory object used to populate that object. There are pros and cons to each but for a simple demonstration (and without getting into async middleware) it's probably easiest to inject the key store into the response object as part of the middleware.
Create a new file, src/middleware/InjectJwks.js
, and put in the following:
/**
* Inject the JWKS store into the request.
* @param {any} store The JWKS store.
* @returns The next middleware in the chain.
*/
const injectJwks = (store) => {
return function (req, res, next) {
res.locals.store = store
next()
}
}
module.exports = injectJwks
This is a pretty simple middleware but the signature is not a normal middleware signature. This is because we're using a wrapper function to handle injecting our store into the request (technically in the response). You can think of this as a more dynamic version of the venerable Express middleware.
Back in src/index.js
we'll import the new middleware and add it to the /api/validate
route:
const validate = require('./middleware/Validate.js')
// Add this line:
const injectJwks = require('./middleware/InjectJwks.js')
// ...
// And then update this line:
app.post('/api/validate', injectJwks(store), validate, (req, res) => {
Note that instead of simply passing injectJwks
we're calling a function that returns another function, this time with the correct signature. This bit of gymnastics will ensure that the key store is injected into a variable accessible for the next middleware component.
The other step in the validation process is updating the middleware to use the store we're now injecting into the response. Open up src/middleware/Validate.js
and let's make a few changes.
First, I'm going to tweak slightly how the public key is being generated. Instead of calling KEYUTIL.getKey()
directly, I'm going to pull that piece of functionality out into its own function so we can reuse it later:
const fromBase64Url = (str) => Buffer.from(str, 'base64url').toString()
// Add this function:
const getPublicKey = (keyString) => KEYUTIL.getKey(keyString.replace(/[\n\r]+/g, ''))
const jwtSecret = process.env.JWT_SECRET
// And update this line:
const publicKey = getPublicKey(readFile(path.join(__dirname, '..', '..', 'certs', 'pubkey.pem')))
Now, we have a convenience method that takes a PEM string, replaces any newline characters, and generates an RSAKey
object from the string. This is important because in this example we're mixing libraries - the jsrsasign
library that we're using for validation doesn't handle key objects in quite the same way as the node-jose
library that we're using for the JWKS store does. I experimented with a few ways of going back and forth but ultimately using the publicKeyAsPem()
method on the store to export a PEM public key seemed to work the best. Inside the validate()
method, let's update a little logic:
const [ header, payload ] = token.split('.')
// Update this line to extract the key id as well as the algorithm:
const { alg, kid } = JSON.parse(fromBase64Url(header))
let validationComponent = null,
isValid = false
try {
if (/^HS/.test(alg)) {
// HSxxx algorithms use a shared secret.
validationComponent = jwtSecret
} else if (/^[REP]S/.test(alg)) {
// RSxxx, ESxxx, and PSxxx algorithms all use a public key.
validationComponent = publicKey
// If there's a kid defined in the header, we can assume for demonstration
// purposes that we're dealing with a JWKS request.
if (kid) {
// Even though we have an endpoint serving the JWKS, we can get some
// performance benefit by using the in-memory store instead of the endpoint.
if (res.locals.store) {
const key = res.locals.store.publicKeyAsPem(kid)
validationComponent = getPublicKey(key)
} else {
// If a kid was specified but not found in the store, throw an error.
res.status(500).send('No matching KID found')
}
}
} else {
// Those are the only options; if another one was specified, throw an error.
console.log(`Invalid algorithm specified: ${alg}`)
res.status(501).send('Not Implemented')
}
} catch (e) {
console.log(e)
res.status(500)
}
The new block of code in the try...catch
block first uses the fallback public key that we hardcoded at the top of the file. But then, we check if there's a key ID in the header (if the destructuring didn't find a kid
key, the variable will be undefined
). If not, we don't do anything; but if so, we do a further check to ensure that we have a JWKS store in res.locals.store
. If not, we throw an error and the middleware terminates. Only if there's a kid
specified, and we have a valid store, do we extract the public key as a PEM string by kid
and pass it to the getPublicKey()
convenience method we just added. This will ensure that we have a valid RSAKey
object from the jsrsasign
library to do our validation. We assign that object to validationComponent
, and the rest of the middleware is fine as-is.
Testing everything together
At this point in the project, we have an Express server with a route requiring JWT validation. We've already tested the validation using pre-shared keys (HS256 algorithm) and a single, static public key (RS256 algorithm). Now let's test multiple tokens signed with different keys.
The test script we were using earlier (src/testJWKS.js
) will take a single payload and sign it with two different private keys (and therefore the tokens will have different kid
values and different public keys). Re-run that script so that we can get two separate tokens. Once you have those tokens, you can run different curl commands:
$ curl -X POST http://localhost:3000/api/validate -H "Authorization: Bearer [jwt 1]"
{"sub":"1234567890","name":"John Doe","iat":1740245256,"iss":"ExampleJWKS"}
$ curl -X POST http://localhost:3000/api/validate -H "Authorization: Bearer [jwt 2]"
{"sub":"1234567890","name":"John Doe","iat":1740245256,"iss":"ExampleJWKS"}
Wrapping Up the Series
JSON Web Tokens (JWTs) are a secure, flexible way to provide additional authorization to your applications at the edge. But they can be tricky to manage at scale, especially if you use a public/private keypair setup to manage the token signing, or you're interfacing with another system that does this. One of the ways to make this process a little less painful is to employ JSON Web Key Sets (JWKS), which is a system to maintain multiple public keys in an automated way, and then use this JWKS file to pick the right public key to validate your JWT.
Parts 1 and 3 dealt with the theory of JWTs and JWKS; parts 2 and 4 provided some very basic code samples that you should definitely not just put into production; hopefully, those samples helped explain the workflow and demonstrated end-to-end what the JWT validation process might look like.
The code for the finished repo is up on Github should you want to look at it all together, clone it, tweak it, etc. It's all open-source, so by all means please take it and learn from it.
I hope this has been helpful!
Top comments (0)