Validate an OpenID Connect JWT using a public key in JWKS
Axel Navarro
Posted on March 30, 2023
You may have used OpenID Connect in the Front-end, where an IDP (IDentity Provider) authenticates a user, gives you a bunch of tokens in the browser and then you can add the Authorization
header to your HTTP requests to your own Back-End because you trust this IDP. But what happens when you can't find where you can verify the id_token
(or access_token
) using some endpoint in the IDP?
Well, I found how an OpenID Connect id_token
should be validated. It wasn't straightforward in my case: I had to do a lot of research to validate my id_token
. Let's see how to make this easy using Node.js and the jsonwebtoken
npm package made by Auth0.
Understanding JWT to know how to validate it
A JSON Web Token (JWT) is a string built with 2 JSON objects encoded in base64
and a signature; these parts are joined by a period (.
) with the following structure: <header>.<payload>.<signature>
.
π‘ This is why a JWT always starts with ey
, because it is the result of encoding {"
using base64
, which is the beginning of any JSON.
In the header
part we can find which signature algorithm was used in the alg
parameter (e.g. RS256) to sign the JWT, and the kid
parameter tells which Key ID from the JSON Web Key Set (JWKS) was used for a given token.
π§ Remember that when the JWT header
has a Key ID (kid
), JWKS is used.
And here is where the problem starts. Where do I find the JWKS to get the public key so we can verify the integrity of this token? π«
The issuer
The issuer is the one who created and signed the JWT, and we can know this by checking the value iss
in the payload of our JWT - using jwt.decode
from jsonwebtoken
.
const jwt = require('jsonwebtoken');
const payload = jwt.decode('<your_jwt_here>');
console.log(payload.iss);
Alternatively, you can paste your JWT into https://jwt.io (don't worry, it's a safe website from Auth0), and iss
is there too!
In OpenID Connect, the issuer should be a URL, but it could just be the name of the IDP. In that case you should read more docs to find where the JWKS URI is. π
For our example we can use https://sandrino.auth0.com as an issuer, so you can know the OpenID configuration using the well-know URI https://sandrino.auth0.com/.well-known/openid-configuration, and in the jwks_uri
attribute is where you can find the JWKS for our issuer. You can check this same value in another issuer https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration, the jwks_uri
is there too! π
const jwt = require('jsonwebtoken');
const printJwksUri = async (issuer) => {
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
const {jwks_uri} = await response.json();
console.log(jwks_uri);
};
const token = '<your_jwt>';
const {iss} = jwt.decode(token);
printJwksUri(iss);
We have the JWKS URI programmatically in Node.js! π₯³
Verifying the token
Now that we know enough about JWKS, we can write a Node.js code to validate an OpenID token that discovers the JWKS URI if you don't know where it is.
const {promisify} = require('node:util');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const fetchJwksUri = async (issuer) => {
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
const {jwks_uri} = await response.json();
return jwks_uri;
};
const getKey = (jwksUri) => (header, callback) => {
const client = jwksClient({jwksUri});
client.getSigningKey(header.kid, (err, key) => {
if (err) {
return callback(err);
}
callback(null, key.publicKey || key.rsaPublicKey);
});
};
/**
* Verify an OpenID Connect ID Token
* @param {string} token - The JWT Token to verify
*/
const verify = async token => {
const {iss: issuer} = jwt.decode(token);
const jwksUri = await fetchJwksUri(issuer);
return promisify(jwt.verify)(token, getKey(jwksUri));
};
const token = '<your_jwt>';
verify(token)
.then(() => console.log('Token verified successfully.'))
.catch(console.error);
β οΈ Did you notice that I didn't check who is the issuer? This code will accept any of them, even a malicious one. π¨ To control this just accept issuers from an allowed list.
const allowedIssuers = [
'https://login.microsoftonline.com/common/v2.0',
'https://sandrino.auth0.com',
];
const fetchJwksUri = async (issuer) => {
if (!allowedIssuers.includes(issuer)) {
throw new Error(`The issuer ${issuer} is not trusted here!`);
}
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
const {jwks_uri} = await response.json();
return jwks_uri;
};
With this little change you're safe now π
Conclusion
Like I mentioned in this post, these well-known URIs are a standard method to get information for specific features, and issuers should implement the /.well-known/openid-configuration
to integrate it easier to your authentication flow.
π§ OpenID Connect is a safe way to authenticate users, but you always have to verify the token's integrity in the Back-End side and check if it was created by a trusted issuer.
π Remember that this scenario only works with JWKS (when the certificate is pre-distributed to the clients the JWT header
has x5t
instead of kid
). You can find examples with public.pem
files in the jsonwebtoken
package.
πͺ Bonus tip if you use Autenticar service from the Argentinian government, a.k.a AFIP Clave Fiscal, you can use this code to validate the id_token
with the JWKS.
Posted on March 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.