Start with Identity
Recipe · Beginner · 20 min

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.

JWTSecurityNode.js
Before you start
  • 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 said alg: 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 exp but ignoring clock skew, or vice versa. Use a small clockTolerance, 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.

Free to read and use. Code is illustrative, vendor-neutral, and built on the open standards. Review it against your provider's docs and your threat model before shipping. See more in the recipes index and the standards library.