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:
| Step | Check | Who usually does it |
|---|---|---|
| 1 | Decrypt, if encryption was registered | library |
| 2 | iss exactly equals the issuer from discovery | library, if you pass the issuer |
| 3 | aud contains your client_id, and no audience you do not trust | library does the first half; the second half is often yours |
| 4, 5 | azp, if present, is validated (may include: it equals your client_id) | varies |
| 6 | Signature, with the issuer's keys; optional only for a token received directly from the token endpoint over TLS | library |
| 7 | alg is RS256 or the id_token_signed_response_alg you registered | library, if you pin it |
| 8 | For HS256/384/512, the key is your client_secret | library |
| 9 | Current time is before exp | library |
| 10 | iat not too far from now | you |
| 11 | nonce equals the value sent in the request | you, or a library that takes the expected nonce |
| 12 | acr is acceptable, if requested | you |
| 13 | auth_time is recent enough, if requested or max_age was sent | you |
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.
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:
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 listsweb-appinaud. A check that only asks "doesaudcontain 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: itsazpisother-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_hashandc_hashbind the access token and the code to the ID token. In the hybrid flow the front-channel ID token travels through the browser, andc_hashis 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:
// 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}`);
}
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:
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)
}
}
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:
# 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')}")
== 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/authorizeare v1.0 withisshttps://sts.windows.net/{tid}/; from/oauth2/v2.0/authorizethey are v2.0 withisshttps://login.microsoftonline.com/{tid}/v2.0(ID token claims reference). Theverclaim 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'srequestedAccessTokenVersionin 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'stid, check thattidis a GUID and thatissis exactly the template with thattid, and to check theissuerproperty 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
tidvalues. Personal Microsoft accounts have the fixed tenant9188040d-6c67-4c5b-b112-36a304b66dad. - Identify users by
tidplusoidorsub. The samesubin two tenants is two different users.
Google: two issuer spellings and azp for Android
issis eitherhttps://accounts.google.comoraccounts.google.com. Google's OpenID Connect documentation lists both as valid, while the discovery document carries only thehttps://form. A strict validator that takes the issuer from discovery rejects tokens with the short form; accept exactly these two strings (jose'sissueroption takes an array) and nothing looser.audis your web client ID even when an Android app asked. When an Android app requests an ID token for its backend,audis the backend's web client ID andazpis the Android app's client ID. Put your Android client IDs in the list of trusted presenters; do not drop theazpcheck.hdrestricts Workspace domains. If only users of certain Google Workspace domains may sign in, check thehdclaim; 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/. Configuringhttps://your-tenant.auth0.comwithout 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 pinalgorithms: ["RS256"]so a configuration change cannot silently switch your verifier to HMAC.
Common mistakes
- 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). - 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.
- Not storing the nonce. Generating a nonce and never comparing it is the same as not sending one.
- Logging users in by
email. Onlyissplussubis 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. - Hard-coding keys. Fetch them from
jwks_uriand follow rotations; see JWKS key rotation without downtime.
Sources
- OpenID Connect Core 1.0 incorporating errata set 2: §2 (ID token), §3.1.3.7, §3.1.3.8, §3.3.2.10, §3.3.2.11, §5.7
- OpenID Connect Core 1.0 incorporating errata set 1, for the earlier
azprule - RFC 9207: OAuth 2.0 authorization server issuer identification
- Microsoft: ID token claims reference, access tokens and issuer validation
- Google: OpenID Connect
- Auth0: validate ID tokens, signing algorithms
- openid-client and go-oidc documentation