Validate a JWT correctly
Verify a JSON Web Token's signature against a provider's JWKS and check every claim that matters, with the pitfalls that cause real vulnerabilities.
- A JWT to validate (an ID token or access token)
- The issuer's JWKS URI, from its OIDC discovery document
- Node.js and the jose library, or any equivalent JWT library
A JWT (JSON Web Token, RFC 7519) is only as trustworthy as your validation of it. Decoding a JWT is not validating it: the payload is base64url, readable by anyone. Validation means verifying the signature and checking the claims. Skipping either step is how token forgery happens. To inspect a token interactively without trusting it, use the JWT decoder tool.
What a JWT looks like
Three base64url segments separated by dots: header.payload.signature.
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9.eyJpc3MiOiJ...fQ.SflKxw...
The header names the signing algorithm (alg) and key id (kid). The payload carries the claims. The signature covers header.payload.
1. Fetch the signing keys (JWKS)
Providers publish their public keys at a JWKS endpoint, listed as jwks_uri in the discovery document. A good library fetches and caches these, selecting the key whose kid matches the token header.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://id.example.com/.well-known/jwks.json'),
);
2. Verify the signature and claims together
Do not verify the signature and then separately parse claims with a different code path. Pass the expected issuer, audience, and algorithms into the verify call so a single function enforces all of them.
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://id.example.com', // must equal the iss claim
audience: 'your-client-id-or-api', // must equal/contain the aud claim
algorithms: ['RS256'], // pin the algorithm; never trust the header alone
clockTolerance: 5, // seconds of leeway for clock skew
});
// payload is now trusted: { sub, iss, aud, exp, iat, ... }
jwtVerify checks the signature against the JWKS and validates exp (expiry) and nbf (not-before) automatically. By passing issuer and audience, you also enforce those.
3. Check the remaining claims yourself
The library validates the standard time and identity claims, but application-specific ones are on you.
if (payload.nonce && payload.nonce !== expectedNonce) {
throw new Error('nonce mismatch'); // bind ID token to your auth request
}
if (typeof payload.sub !== 'string') {
throw new Error('missing subject');
}
// For access tokens, check scope/roles before authorizing:
const scopes = String(payload.scope ?? '').split(' ');
if (!scopes.includes('read:reports')) throw new Error('insufficient_scope');
The pitfalls that cause breaches
alg: none. Some libraries historically accepted unsigned tokens when the header saidalg: none. Always pin an allow-list of algorithms; never read the algorithm from the token to decide how to verify it.- Algorithm confusion (RS256 to HS256). If your code passes the RSA public key as the secret to an HMAC verifier, an attacker can sign a token with that public key as the HMAC secret. Pinning
algorithms: ['RS256']prevents this. - Skipping
aud. A token minted for another service should not be accepted by yours. Always check the audience. - Trusting
expbut ignoring clock skew, or vice versa. Use a smallclockTolerance, not a large one. - Decoding without verifying. Reading claims from an unverified token and acting on them is equivalent to having no authentication at all.
Do not validate by calling the issuer on every request
Signature verification is local and fast once the JWKS is cached. Refresh the JWKS only when you see an unknown kid (key rotation). For opaque (non-JWT) access tokens, you instead use token introspection (RFC 7662) against the provider.
References: OpenID Connect defines ID token validation; OAuth 2.0 covers access tokens.