Skip to content
ecdsa.com
← All articles

OIDC ID token validation, step by step

An ID token is a JWT with rules of its own. Thirteen numbered steps in the specification decide whether you have actually authenticated a user or just parsed a signed blob. Here is each step, a validator that implements them, and what it caught when fed real tokens from a Keycloak realm.

Short answer. For every OpenID Connect ID token: verify the signature with a key from the provider's jwks_uri and only the algorithm you registered (RS256 by default); require iss to equal the discovery document's issuer exactly; require aud to contain your client_id and nothing you do not trust; if azp is present, it must be one of your own clients; require exp in the future; and compare nonce with the value you stored for this login attempt. Check at_hash and c_hash when the token comes with an access token or a code. Then identify the user by the pair iss and sub, never by email.

The thirteen steps

OpenID Connect Core 1.0 §3.1.3.7, in the current text incorporating errata set 2 (December 2023), lists the checks a client performs on an ID token from the token endpoint. In short:

StepCheckWho usually does it
1Decrypt, if encryption was registeredlibrary
2iss exactly equals the issuer from discoverylibrary, if you pass the issuer
3aud contains your client_id, and no audience you do not trustlibrary does the first half; the second half is often yours
4, 5azp, if present, is validated (may include: it equals your client_id)varies
6Signature, with the issuer's keys; optional only for a token received directly from the token endpoint over TLSlibrary
7alg is RS256 or the id_token_signed_response_alg you registeredlibrary, if you pin it
8For HS256/384/512, the key is your client_secretlibrary
9Current time is before explibrary
10iat not too far from nowyou
11nonce equals the value sent in the requestyou, or a library that takes the expected nonce
12acr is acceptable, if requestedyou
13auth_time is recent enough, if requested or max_age was sentyou

Two related checks sit in other sections: at_hash ties the access token to the ID token (§3.1.3.8, optional in the code flow), and c_hash ties the authorization code to an ID token returned from the authorization endpoint in the hybrid flow (§3.3.2.10). Both are the left half of the hash of the token or code, base64url-encoded, using the hash of the ID token's alg: for RS256, the first 128 bits of SHA-256.

A validator that implements them

The validator below uses jose for what a JWT library does well (signature, alg, iss, aud containment, exp) and adds the steps a JWT library cannot know about, because they depend on the login attempt or on your client registration.

validate-id-token.mjs (jose 6.2.13, Node.js 24.22)
import { createHash } from "node:crypto";
import { jwtVerify } from "jose";

// at_hash and c_hash: the left half of the hash of the ASCII value, base64url-encoded,
// with the hash function of the ID token's alg (OIDC Core 3.1.3.6 and 3.3.2.11).
const HASH = { RS256: "sha256", PS256: "sha256", ES256: "sha256", RS384: "sha384", PS384: "sha384", ES384: "sha384", RS512: "sha512", PS512: "sha512", ES512: "sha512" };
export function halfHash(value, alg) {
  const digest = createHash(HASH[alg]).update(value, "ascii").digest();
  return digest.subarray(0, digest.length / 2).toString("base64url");
}

/**
 * Validates an ID token as OpenID Connect Core 1.0 §3.1.3.7 describes.
 * rp:       { issuer, jwks, clientId, alg, trustedAudiences, trustedPresenters, maxIatAgeSeconds }
 * expected: { nonce, maxAge?, accessToken?, code? } from this login's stored state and the response
 */
export async function validateIdToken(idToken, rp, expected) {
  // Steps 2, 3 (aud contains client_id), 6, 7 and 9: issuer, audience, signature, alg, expiry.
  const { payload: claims, protectedHeader } = await jwtVerify(idToken, rp.jwks, {
    issuer: rp.issuer, // exact string from the discovery document
    audience: rp.clientId,
    algorithms: [rp.alg], // RS256 unless id_token_signed_response_alg says otherwise
    requiredClaims: ["iss", "sub", "aud", "exp", "iat"],
    clockTolerance: 30,
  });
  const now = Math.floor(Date.now() / 1000);
  const fail = (step, why) => {
    throw new Error(`step ${step}: ${why}`);
  };

  // Step 3, second half: reject audiences this client does not trust.
  const untrusted = [claims.aud].flat().filter((a) => a !== rp.clientId && !rp.trustedAudiences.includes(a));
  if (untrusted.length > 0) fail(3, `untrusted audience ${untrusted.join(", ")}`);
  // Steps 4-5: an azp, when present, must name a client of this application.
  if (claims.azp !== undefined && ![rp.clientId, ...rp.trustedPresenters].includes(claims.azp)) {
    fail(5, `azp is ${claims.azp}`);
  }
  // Step 10: iat bounds how long nonces must be remembered.
  if (now - claims.iat > rp.maxIatAgeSeconds) fail(10, `issued ${now - claims.iat} s ago`);
  // Step 11: the nonce stored with this login attempt, compared exactly.
  if (claims.nonce !== expected.nonce) fail(11, `nonce ${claims.nonce} does not match the stored value`);
  // Step 13: when max_age was sent, auth_time is required and bounded.
  if (expected.maxAge !== undefined) {
    if (typeof claims.auth_time !== "number") fail(13, "auth_time missing");
    if (now - claims.auth_time > expected.maxAge + 30) fail(13, "authentication too old");
  }
  // Hashes: tie the access token (at_hash) and the code (c_hash) to this ID token.
  if (expected.accessToken !== undefined && claims.at_hash !== undefined) {
    if (claims.at_hash !== halfHash(expected.accessToken, protectedHeader.alg)) fail("3.1.3.8", "at_hash mismatch");
  }
  if (expected.code !== undefined) {
    if (claims.c_hash !== halfHash(expected.code, protectedHeader.alg)) fail("3.3.2.10", "c_hash missing or mismatch");
  }
  return claims;
}

rp.jwks is createRemoteJWKSet(new URL(discovery.jwks_uri)), created once per issuer. Step 1 is absent because the lab does not encrypt ID tokens, and steps 8 and 12 because it uses RS256 and does not request acr.

Run against real tokens

To test the validator against tokens from a real provider rather than hand-made ones, the lab ran Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, dev mode) with a realm containing one user and two confidential clients: web-app, the relying party being protected, and other-app, another application of the same provider configured with an audience mapper so that its ID tokens also list web-app. A small harness played the browser: it opened the authorization URL with a fresh state, nonce and PKCE challenge, submitted the login form, took the code from the redirect and exchanged it at the token endpoint. Every ID token then went through validateIdToken as web-app:

Output: real logins against Keycloak 26.8.1, validated as web-app (node:24.22.0-bookworm-slim)
issuer http://127.0.0.1:8080/realms/demo
code flow callback parameters: state, session_state, iss, code
ID token header: alg RS256, typ JWT, kid present: true
ID token claims: exp, iat, auth_time, jti, iss, aud, sub, typ, azp, nonce, sid, at_hash, acr, email_verified, name, preferred_username, given_name, family_name, email
aud "web-app", azp web-app, at_hash present
code flow ID token                                   valid
hybrid callback parameters: state, session_state, iss, code, id_token
front-channel ID token: c_hash present, at_hash absent
hybrid front-channel ID token, with its code         valid
same ID token, code from the first login             rejected: step 3.3.2.10: c_hash missing or mismatch
other-app ID token: aud ["other-app","web-app"], azp other-app
other-app token, aud-contains check only             valid
other-app token, full validation                     rejected: step 3: untrusted audience other-app
first ID token, nonce of the second login            rejected: step 11: nonce lsamKJG6vC-xAIOzo1LIrw does not match the stored value
first ID token, access token of the second login     rejected: step 3.1.3.8: at_hash mismatch

What each rejection prevents:

  • An ID token minted for another application. other-app's token is signed by the same provider and lists web-app in aud. A check that only asks "does aud contain my client ID", which is what most JWT libraries do, accepted it. Step 3 says to reject audiences you do not trust, and step 5 would have caught it too: its azp is other-app. Without either, any application registered with the same provider, including one an attacker registers, can obtain tokens that sign users into yours.
  • A token from a different login attempt. The nonce check rejected a valid, unexpired, correctly signed ID token because it belonged to a different authorization request. That is what stops an attacker from injecting a token or code obtained in their own session into a victim's login (step 11), and why the nonce must be stored per login attempt, typically in the same server-side session or encrypted cookie as state.
  • Mixed-up artifacts. at_hash and c_hash bind the access token and the code to the ID token. In the hybrid flow the front-channel ID token travels through the browser, and c_hash is what proves the code next to it is the one the provider issued with it.

Note the iss parameter in both callbacks. Keycloak adds it to the authorization response (RFC 9207), which lets a client that talks to several providers detect a response from the wrong one before redeeming the code.

What libraries do for you

A certified OpenID Connect client library performs most of these steps if you hand it the values it needs. openid-client is one; given the expected nonce, it checks it:

openid-client-flow.mjs (openid-client 6.8.8)
// The library route: openid-client 6.8.8 runs discovery, the code exchange and the ID token checks.
import * as client from "openid-client";
import { browserLogin } from "./login.mjs";

const issuer = new URL("http://127.0.0.1:8080/realms/demo");
// allowInsecureRequests only because the lab issuer is plain http; never in production.
const config = await client.discovery(issuer, "web-app", process.env.WEB, undefined, {
  execute: [client.allowInsecureRequests],
});

async function signIn(expectedNonceOverride) {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: "http://127.0.0.1:3000/callback",
    scope: "openid email",
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
  const { location } = await browserLogin(url, "alice", process.env.PW);
  const tokens = await client.authorizationCodeGrant(config, new URL(location), {
    pkceCodeVerifier: codeVerifier,
    expectedState: state,
    expectedNonce: expectedNonceOverride ?? nonce,
    idTokenExpected: true,
  });
  return tokens.claims();
}

const claims = await signIn();
console.log(`signed in: sub ${claims.sub.slice(0, 8)}..., email ${claims.email}, nonce checked`);
try {
  await signIn(client.randomNonce());
} catch (e) {
  console.log(`wrong expectedNonce: ${e.name}: ${e.message}; cause: ${e.cause?.message}`);
}
Output
signed in: sub aa5e60df..., email alice@example.com, nonce checked
wrong expectedNonce: ClientError: unexpected JWT claim value encountered; cause: unexpected ID Token "nonce" claim value

browserLogin is the lab harness standing in for the user's browser; in an application, the authorization URL is a redirect and location is the callback request URL. openid-client's lower layer, oauth4webapi 3.8.8, also applies the errata-1 rule for azp: when aud has more than one value, azp must be present and equal the client ID.

go-oidc v3.21.0 is a verifier rather than a full client, and its documentation says plainly that "Verify does NOT do nonce validation, which is the caller's responsibility." The lab driver wrote two fresh tokens from the same realm, the nonce it sent, and a nonce and access token from another login to a file, and this program checked them:

main.go (go-oidc v3.21.0, Go 1.27.3)
package main

// What go-oidc's IDTokenVerifier checks on its own, against ID tokens from the lab Keycloak realm.

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
)

func main() {
	var h struct {
		Issuer, IDToken, AccessToken, NonceSent, NonceOfAnotherLogin, OtherAppIDToken, OtherAccessToken string
	}
	data, _ := os.ReadFile("/node/handoff.json")
	if err := json.Unmarshal(data, &h); err != nil {
		panic(err)
	}
	ctx := context.Background()
	ctx = oidc.InsecureIssuerURLContext(ctx, h.Issuer) // only because the lab issuer is plain http
	provider, err := oidc.NewProvider(ctx, h.Issuer)
	if err != nil {
		panic(err)
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: "web-app"})

	token, err := verifier.Verify(ctx, h.IDToken)
	fmt.Printf("%-44s err=%v\n", "Verify(web-app ID token)", err)
	fmt.Printf("%-44s %v\n", "  nonce equals the nonce we sent", token.Nonce == h.NonceSent)
	fmt.Printf("%-44s %v\n", "  nonce equals another login's nonce", token.Nonce == h.NonceOfAnotherLogin)
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(its access token)", token.VerifyAccessToken(h.AccessToken))
	fmt.Printf("%-44s err=%v\n", "  VerifyAccessToken(another access token)", token.VerifyAccessToken(h.OtherAccessToken))

	other, err := verifier.Verify(ctx, h.OtherAppIDToken)
	if err == nil {
		var c struct {
			Azp string `json:"azp"`
		}
		_ = other.Claims(&c)
		fmt.Printf("%-44s err=<nil>, aud=%v, azp=%s\n", "Verify(other-app ID token)", other.Audience, c.Azp)
	} else {
		fmt.Printf("%-44s err=%v\n", "Verify(other-app ID token)", err)
	}
}
Output (golang:1.27.3-trixie)
Verify(web-app ID token)                     err=<nil>
  nonce equals the nonce we sent             true
  nonce equals another login's nonce         false
  VerifyAccessToken(its access token)        err=<nil>
  VerifyAccessToken(another access token)    err=access token hash does not match value in ID token
Verify(other-app ID token)                   err=<nil>, aud=[other-app web-app], azp=other-app

Verify accepted the first token without knowing any nonce; the comparison in the next two lines is the caller's. It also accepted the token minted for other-app, because its audience check is containment only and it does not look at azp. With go-oidc, compare idToken.Nonce with your stored value, call idToken.VerifyAccessToken when you use the access token, and check the audience list and azp yourself if your provider can issue tokens with several audiences. The errors go-oidc does raise are in the catalog, for example expected audience and issuer did not match.

Provider differences that break validators

What the discovery documents of four providers said on 20 October 2026:

discovery.py
# Prints issuer, jwks_uri and ID token signing algorithms from public OpenID Connect discovery documents.
import json
import urllib.request

URLS = [
    "https://accounts.google.com/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
    "https://login.microsoftonline.com/common/.well-known/openid-configuration",
    "https://samples.auth0.com/.well-known/openid-configuration",
    "https://appleid.apple.com/.well-known/openid-configuration",
]
for url in URLS:
    with urllib.request.urlopen(url, timeout=20) as response:
        doc = json.load(response)
    print(f"== {url}")
    print(f"   issuer   {doc['issuer']}")
    print(f"   jwks_uri {doc['jwks_uri']}")
    print(f"   id_token_signing_alg_values_supported {doc.get('id_token_signing_alg_values_supported')}")
Output (python:3.14.8-slim-trixie)
== https://accounts.google.com/.well-known/openid-configuration
   issuer   https://accounts.google.com
   jwks_uri https://www.googleapis.com/oauth2/v3/certs
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
   issuer   https://login.microsoftonline.com/{tenantid}/v2.0
   jwks_uri https://login.microsoftonline.com/common/discovery/v2.0/keys
   id_token_signing_alg_values_supported ['RS256']
== https://login.microsoftonline.com/common/.well-known/openid-configuration
   issuer   https://sts.windows.net/{tenantid}/
   jwks_uri https://login.microsoftonline.com/common/discovery/keys
   id_token_signing_alg_values_supported ['RS256']
== https://samples.auth0.com/.well-known/openid-configuration
   issuer   https://samples.auth0.com/
   jwks_uri https://samples.auth0.com/.well-known/jwks.json
   id_token_signing_alg_values_supported ['HS256', 'RS256', 'PS256']
== https://appleid.apple.com/.well-known/openid-configuration
   issuer   https://appleid.apple.com
   jwks_uri https://appleid.apple.com/auth/keys
   id_token_signing_alg_values_supported ['RS256']

Microsoft Entra ID: two token versions and a template issuer

  • The version follows the endpoint. ID tokens from /oauth2/authorize are v1.0 with iss https://sts.windows.net/{tid}/; from /oauth2/v2.0/authorize they are v2.0 with iss https://login.microsoftonline.com/{tid}/v2.0 (ID token claims reference). The ver claim says which. A validator configured with one issuer format rejects the other, and the .NET message is IDX10205: Issuer validation failed. For access tokens the version is chosen by the API's requestedAccessTokenVersion in the app manifest, not by the endpoint.
  • {tenantid} is not a value. The multi-tenant discovery documents above return an issuer with a literal {tenantid} placeholder. An exact comparison against that string never matches. Microsoft's access token guidance says to substitute the token's tid, check that tid is a GUID and that iss is exactly the template with that tid, and to check the issuer property that Entra attaches to each key in its JWKS.
  • Restrict tenants yourself. A multi-tenant configuration accepts users from every Entra tenant, including ones an attacker creates. If your application serves specific organizations, keep an allowlist of tid values. Personal Microsoft accounts have the fixed tenant 9188040d-6c67-4c5b-b112-36a304b66dad.
  • Identify users by tid plus oid or sub. The same sub in two tenants is two different users.

Google: two issuer spellings and azp for Android

  • iss is either https://accounts.google.com or accounts.google.com. Google's OpenID Connect documentation lists both as valid, while the discovery document carries only the https:// form. A strict validator that takes the issuer from discovery rejects tokens with the short form; accept exactly these two strings (jose's issuer option takes an array) and nothing looser.
  • aud is your web client ID even when an Android app asked. When an Android app requests an ID token for its backend, aud is the backend's web client ID and azp is the Android app's client ID. Put your Android client IDs in the list of trusted presenters; do not drop the azp check.
  • hd restricts Workspace domains. If only users of certain Google Workspace domains may sign in, check the hd claim; Google's documentation says its absence means the account does not belong to a Google-hosted domain. The email domain alone is not a substitute.

Auth0: a trailing slash and HS256

  • The issuer ends with a slash: https://samples.auth0.com/. Configuring https://your-tenant.auth0.com without it fails every token; the go-oidc version of this error is issuer did not match, whose example is exactly a missing trailing slash.
  • ID tokens may be HS256. The discovery document lists HS256, and an application configured for it receives ID tokens signed with its client secret (step 8). That works for a confidential server-side client, but it means the secret is a signing key, and any service that knows it can mint ID tokens. Auth0's own signing algorithms page recommends RS256. Choose it in the application settings and pin algorithms: ["RS256"] so a configuration change cannot silently switch your verifier to HMAC.

Common mistakes

  1. Validating an ID token as if it were an access token, or the reverse. An ID token tells your client who signed in; it is not a credential for calling your API. APIs should accept access tokens with their own audience (JWT verification mistakes covers typ: at+jwt).
  2. Skipping the signature because "it came over TLS". Step 6 allows that only for a token your server received directly from the token endpoint. An ID token a mobile app or a browser posts to your backend came from the client, not from the provider, and must be verified in full.
  3. Not storing the nonce. Generating a nonce and never comparing it is the same as not sending one.
  4. Logging users in by email. Only iss plus sub is stable and unique (§5.7). Matching on email lets anyone who can get an unverified address at any provider you accept take over the account with that address.
  5. Hard-coding keys. Fetch them from jwks_uri and follow rotations; see JWKS key rotation without downtime.

Sources