Validating JWTs with JWKS

OAuth, OIDC & JWT · Practical · 15 min

Every API that accepts JWT access tokens must validate them correctly; mistakes here are critical vulnerabilities.

To trust a JWT, a resource server must do all of these, ideally with a well-maintained library such as jose (Node), PyJWT, or the framework's built-in middleware:

  • Fetch the issuer's public keys from its JWKS endpoint (jwks_uri in the discovery document), cache them, and pick the key matching the token's kid. Refetch on an unknown kid (keys rotate), with rate limiting.
  • Pin the algorithms you accept (e.g. only RS256 or ES256). Never let the token's header decide.
  • Verify the signature.
  • Check iss equals the expected issuer exactly, and aud contains your API's identifier. Without the audience check, a token issued for another API from the same AS would work against yours.
  • Check exp and nbf with a small clock skew allowance (30 to 60 seconds).
  • For access tokens, check typ is at+jwt (RFC 9068) when the AS sets it, so ID tokens cannot be replayed as access tokens.

Only after all checks pass, read sub, scope and other claims. Reject with 401 and a WWW-Authenticate: Bearer error="invalid_token" header when validation fails.

Local JWT validation is fast but cannot see revocation until the token expires; that is the reason access tokens are short-lived. If you need immediate revocation, use opaque tokens with introspection.

Example

import { generateKeyPair, exportJWK, SignJWT, jwtVerify, createLocalJWKSet } from "jose";

// Simulate the authorization server: a key pair and its JWKS
const { publicKey, privateKey } = await generateKeyPair("ES256");
const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: "k1", alg: "ES256" }] };
const token = await new SignJWT({ scope: "orders.read" })
  .setProtectedHeader({ alg: "ES256", kid: "k1", typ: "at+jwt" })
  .setIssuer("https://auth.example.com").setAudience("https://api.example.com")
  .setSubject("user-42").setIssuedAt().setExpirationTime("15m").sign(privateKey);

// The API: in production use createRemoteJWKSet(new URL(jwks_uri))
const JWKS = createLocalJWKSet(jwks);
const { payload } = await jwtVerify(token, JWKS, {
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
  algorithms: ["ES256"],
  typ: "at+jwt",
  clockTolerance: 30,
});
console.log(payload.sub, payload.scope); // user-42 orders.read

Remember

  • Get keys from the issuer's JWKS by kid; handle rotation
  • Pin accepted algorithms; never trust the header's alg
  • Always check iss, aud, exp (and typ for access tokens)
  • JWTs stay valid until exp: keep them short-lived

Watch out for

  • Skipping the audience check, so tokens minted for another API are accepted by yours.

Check yourself

  1. A valid token from your AS was issued for `aud: billing-api`. Your orders API accepts it. What check is missing?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc9068

Validating JWTs with JWKS · OAuth, OIDC & JWT · Topolearn