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_uriin the discovery document), cache them, and pick the key matching the token'skid. Refetch on an unknownkid(keys rotate), with rate limiting. - Pin the algorithms you accept (e.g. only
RS256orES256). Never let the token's header decide. - Verify the signature.
- Check
issequals the expected issuer exactly, andaudcontains your API's identifier. Without the audience check, a token issued for another API from the same AS would work against yours. - Check
expandnbfwith a small clock skew allowance (30 to 60 seconds). - For access tokens, check
typisat+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.readRemember
- 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.