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 jose library (npm install jose) or openid-client
  • An OIDC provider exposing /.well-known/openid-configuration and a JWKS endpoint
  • The client ID you registered, to check the aud claim
  • The nonce you generated during the authorization request, available at validation time
  • Familiarity with the Authorization Code flow that produced the id_token

Expected Outcomes

  • A cached JWKSet that refetches automatically on key rotation
  • ID token signatures verified against the provider keys using a pinned asymmetric algorithm
  • iss, aud, exp, iat, and nonce all enforced, with alg=none and 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.

OIDC ID Token Validation Gates A checklist diagram listing the validation gates an ID token must pass: signature verified against JWKS with a pinned algorithm, issuer equals the discovery issuer, audience equals the client id, expiry and issued-at within clock tolerance, and nonce equals the value sent, before mapping to a session. Raw ID token (JWT) untrusted until verified 1. signature vs JWKS (RS256) 2. iss = discovery issuer 3. aud = client_id 4. exp / iat in tolerance 5. nonce = sent value Trusted claims map to local session Any failed gate rejects the token; a token is never partly trusted

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.