Validating OIDC ID Tokens in Node.js
An OpenID Connect ID token is a signed JWT that tells your application who just logged in — but only if you actually validate it. Decoding a JWT to read its claims is trivial and proves nothing; a token that has not had its signature checked against the issuer’s keys is attacker-controlled data. A depressing number of integrations base64-decode the payload, read the sub, and mint a session, which lets anyone forge an identity by editing a claim. This guide implements correct ID token validation in Node.js with the jose library: retrieving the JSON Web Key Set through OIDC discovery, verifying the RS256 signature, and enforcing the iss, aud, exp, iat, and nonce claims while rejecting alg=none and symmetric algorithms. It is part of the OAuth 2.0 & OpenID Connect Implementation guide within Secure Authentication & Session Architecture. Because a stolen ID token is a direct path to impersonation, keeping tokens out of reach of injected scripts — the domain of XSS mitigation — is a companion concern to validating them.
Prerequisites
- Node.js 18+ with the
joselibrary (npm install jose) oropenid-client - An OIDC provider exposing
/.well-known/openid-configurationand a JWKS endpoint - The client ID you registered, to check the
audclaim - The
nonceyou generated during the authorization request, available at validation time - Familiarity with the Authorization Code flow that produced the
id_token
Expected Outcomes
- A cached
JWKSetthat refetches automatically on key rotation - ID token signatures verified against the provider keys using a pinned asymmetric algorithm
iss,aud,exp,iat, andnonceall enforced, withalg=noneand HS* rejected- Validated claims mapped to a local session, never trusting an unverified token
Step 1: Discover Endpoints and Build a Cached JWKS
Fetch the provider’s discovery document once to learn the issuer, jwks_uri, and supported algorithms. Then build a remote key set that caches keys and refetches when it encounters an unknown kid. The createRemoteJWKSet helper from jose handles caching, cooldown, and rotation for you — do not hard-code a key, and do not fetch the JWKS on every request.
import { createRemoteJWKSet } from "jose";
interface OidcMeta {
issuer: string;
jwks_uri: string;
id_token_signing_alg_values_supported: string[];
}
let metaCache: OidcMeta | null = null;
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null;
export async function getOidcContext(discoveryUrl: string) {
if (!metaCache) {
const res = await fetch(discoveryUrl);
if (!res.ok) throw new Error(`discovery failed: ${res.status}`);
metaCache = (await res.json()) as OidcMeta;
// JWKS is fetched lazily and cached; it refetches on unknown kid (rotation)
jwksCache = createRemoteJWKSet(new URL(metaCache.jwks_uri), {
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
cooldownDuration: 30 * 1000, // avoid refetch storms
});
}
return { meta: metaCache, jwks: jwksCache! };
}
The claims you will enforce, and why each matters, are summarized below.
Step 2: Verify the Signature and Standard Claims
jose’s jwtVerify performs signature verification and claim checks in one call. Pass the JWKS as the key resolver, pin the acceptable algorithm to the provider’s advertised asymmetric algorithm, and assert issuer and audience. This single call rejects alg=none, wrong-key signatures, expired tokens, and audience mismatches.
import { jwtVerify, JWTPayload } from "jose";
import { getOidcContext } from "./oidc-context";
export async function verifyIdToken(idToken: string, opts: {
discoveryUrl: string; clientId: string; expectedNonce: string;
}): Promise<JWTPayload> {
const { meta, jwks } = await getOidcContext(opts.discoveryUrl);
const { payload } = await jwtVerify(idToken, jwks, {
issuer: meta.issuer, // enforces iss
audience: opts.clientId, // enforces aud
algorithms: ["RS256"], // pin asymmetric alg; rejects none and HS*
clockTolerance: 60, // seconds of allowed skew on exp / iat
maxTokenAge: "12h", // rejects tokens with an old iat
});
// Step 3: nonce must match the value sent in the authorize request
if (payload.nonce !== opts.expectedNonce) {
throw new Error("nonce mismatch — possible token replay or injection");
}
return payload;
}
Pinning algorithms is the single most important line. Without it, a token presenting alg: none is treated as unsigned, and a token presenting HS256 could be verified using the provider’s public key as an HMAC secret — a known forgery path. Never let the token header choose the algorithm.
Step 3: Validate nonce and at_hash
The nonce check in Step 2 binds the token to the specific authentication your client initiated, defeating replay and token injection. When the token endpoint also returned an access token in a hybrid or combined response, validate at_hash to bind the ID token to that access token: at_hash is the base64url-encoded left half of the hash of the access token, computed with the algorithm named in the ID token header.
import { createHash } from "node:crypto";
function computeAtHash(accessToken: string): string {
const digest = createHash("sha256").update(accessToken).digest(); // RS256 -> SHA-256
const half = digest.subarray(0, digest.length / 2);
return half.toString("base64url");
}
export function assertAtHash(payload: { at_hash?: string }, accessToken: string) {
if (payload.at_hash && payload.at_hash !== computeAtHash(accessToken)) {
throw new Error("at_hash mismatch — access token does not match ID token");
}
}
Step 4: Map Validated Claims to a Local Session
Only after every gate passes should you trust sub, email, and the other claims. Use sub (stable and unique per issuer) as the account key — never email, which can change or be reassigned. Provision or look up the local user, then create your own session rather than passing the ID token around as an ambient credential.
export async function establishSession(idToken: string, accessToken: string, ctx: VerifyOpts) {
const claims = await verifyIdToken(idToken, ctx);
assertAtHash(claims as { at_hash?: string }, accessToken);
const user = await upsertUser({
subject: claims.sub as string, // stable account key
issuer: claims.iss as string,
email: claims.email as string | undefined,
});
return createLocalSession(user.id); // your own session, not the ID token
}
Verification
Confirm the validator rejects the forgeries it must reject. Inspect a token’s header before trusting a library, and assert the failure cases in tests:
# Decode only the JWT header to inspect the algorithm and key id
echo "$ID_TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null
# Expect: {"alg":"RS256","kid":"...","typ":"JWT"} — never "alg":"none"
import { describe, it, expect } from "vitest";
import { verifyIdToken } from "./verify";
describe("ID token validation", () => {
it("rejects an alg=none token", async () => {
await expect(verifyIdToken(NONE_ALG_TOKEN, ctx)).rejects.toThrow();
});
it("rejects a wrong audience", async () => {
await expect(verifyIdToken(WRONG_AUD_TOKEN, ctx)).rejects.toThrow();
});
it("rejects a replayed nonce", async () => {
await expect(verifyIdToken(OLD_NONCE_TOKEN, ctx)).rejects.toThrow(/nonce mismatch/);
});
});
Expected: every forged or mismatched token throws, and only a correctly signed token with matching iss, aud, and nonce resolves.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Verification fails after the provider rotates keys | The kid in the token is not in the cached JWKS |
Use createRemoteJWKSet, which refetches on unknown kid; do not pin a single static key |
Intermittent exp failures under load |
Clock drift between your host and the issuer | Set a small clockTolerance (about 60 seconds); do not widen it excessively |
aud check fails when the claim is an array |
Provider issues aud as an array of audiences |
jose accepts a match against any array member; ensure your client id is included and validate azp if present |
nonce mismatch on every login |
The stored nonce is not the one bound to this flow | Persist the nonce per authorization request and pass the same value into validation |
| Token accepted that should be rejected | algorithms was not pinned, allowing none or HS* |
Always pass an explicit algorithms allowlist to jwtVerify |
Common Implementation Mistakes
Frequently Asked Questions
Why must I reject the alg=none and HS256 algorithms for ID tokens?
alg=none declares the token unsigned, so any attacker can forge claims and your verifier would accept them. HS256 is a symmetric algorithm: if a verifier can be tricked into using the provider’s public key as the HMAC secret — a classic algorithm-confusion attack — an adversary who knows that public key can sign arbitrary tokens. The defense is to pin the expected asymmetric algorithm, typically RS256 or ES256, in an explicit allowlist and reject anything else before verification runs. Never let the token’s own header dictate which algorithm is used.
How should JWKS keys be cached and rotated?
Use a remote JWKS helper such as jose’s createRemoteJWKSet that caches keys in memory and refetches when it encounters a kid it has not seen, while respecting the endpoint’s cache headers. This makes routine key rotation transparent: when the provider begins signing with a new key, the next token carrying an unknown kid triggers a single refetch. Hard-coding one key breaks on rotation, and refetching on every request both hammers the provider and opens a denial-of-service vector.
Is checking exp enough, or do I also need to validate iat and clock skew?
Treat exp, iss, and aud as hard requirements. Allow a small clock tolerance — around 60 seconds — on exp and iat so ordinary drift between your host and the issuer does not cause spurious rejections. Keep that tolerance tight, because a generous window widens the replay opportunity. Validating iat (optionally via a maxTokenAge) additionally rejects tokens that are technically unexpired but implausibly old. jose enforces exp automatically and exposes a clockTolerance option for exactly this.
Related
- OAuth 2.0 & OpenID Connect Implementation — the parent guide covering the full flow, redirect validation, and token exchange
- PKCE Authorization Code Flow for SPAs and Mobile Apps — obtaining the ID token this guide validates
- Cross-Site Scripting (XSS) Mitigation — preventing the script injection that steals validated tokens