Skip to main content
Version 0.1Draft

Token validation issues

When LuxID issues an ID Token, your Application is responsible for validating it. Skipping or weakening any of the mandatory checks defined by OpenID Connect Core 1.0 §3.1.3.7 (opens in a new tab) is a security bug. This page walks through the five mandatory checks, the most common failure modes for each, and the library-specific recipes that get them right.

The five mandatory checks

Run all five against every ID Token. None is optional.

  1. Signature. Verify the JWS signature using RS256 and the LuxID public key whose kid matches the JWT header. Fetch the key from the LuxID JWKS endpoint (opens in a new tab) (UAT: replace host).
  2. Issuer. iss MUST equal https://login.luxid.lu (or https://login-uat.luxid.lu in UAT). Exact match, no trailing slash tolerance.
  3. Audience. aud MUST contain your Application's Client ID. If aud is a JSON array, your Client ID must be present and azp (when present) must equal your Client ID.
  4. Expiry. exp MUST be in the future, with up to 60 seconds of clock skew tolerance.
  5. Nonce. If you sent nonce on the authorization request (you should), the nonce claim MUST match exactly.

A token that fails any one of these five is invalid. Do not surface it to your application logic.

Failure-mode catalogue

Signature failure

Symptoms: library raises JWSVerifier / InvalidSignatureException / Signature verification failed.

Most common cause: the kid in the JWT header is not in your cached JWKS because LuxID has rotated keys. LuxID publishes the new key in the JWKS before signing with it, but a long-lived cache means you may not see it yet.

Fix:

  • On kid not found in JWKS, refresh the JWKS from the network and retry once.
  • Set a JWKS cache TTL of 5 to 60 minutes.
  • Never hard-code the public key. Always fetch from JWKS.

Less common causes: you accidentally pinned the algorithm to HS256 and the token is RS256; you are verifying against the wrong environment's JWKS (UAT key vs production token).

iss mismatch

Symptoms: Invalid issuer / iss claim does not match expected value.

Cause: you are mixing environments. The token was issued by UAT but you compare against the production issuer URL, or vice versa.

Fix: pin the expected issuer in configuration, with separate values per environment. Do not derive the issuer from a substring of the JWKS URL or from another claim.

aud mismatch

Symptoms: Invalid audience / aud claim does not contain expected value.

Cause: you compare against a Client ID from a different Application, or you copy-pasted the UAT Client ID into a production runtime.

Fix: pin the expected audience in configuration, separately per environment. When aud is a JSON array, check membership; when it is a string, check equality.

exp in the past

Symptoms: Token expired / JWT is expired.

Cause #1: clock skew on your server. The token was valid when issued but your clock is more than 60 seconds ahead of UTC.

Cause #2: your code path took unusually long to validate (long GC pause, sleeping debugger). The token genuinely expired between issuance and validation.

Fix:

  • Synchronise your server clock via NTP (systemd-timesyncd, chrony, ntpd).
  • Configure a 5-minute clock skew tolerance in your JWT library.
  • Validate ID Tokens immediately on receipt; do not store them and validate later.

iat far in the past or future

Symptoms: depending on the library, either a soft warning or Token issued at time too far in the past/future.

Cause: clock skew, same as exp. If iat is more than a few minutes ahead of your clock, your clock is behind UTC; if iat is hours in the past, the token was buffered somewhere it should not have been.

Fix: NTP. Apply the same 5-minute skew tolerance.

nonce mismatch

Symptoms: Invalid nonce / Nonce claim does not match.

Cause #1: session-state loss between the authorization request and the callback. Your cookie was dropped (cross-site cookie restrictions, browser private mode quirks, SameSite issues), so when the callback arrives your server has no memory of the original nonce.

Cause #2: replay - someone is replaying an old authorization response against your callback.

Fix:

  • Generate a fresh random nonce per authorization request.
  • Store the nonce server-side keyed by a short-lived cookie (or in a signed session cookie).
  • After successful validation, delete the stored nonce so it cannot be replayed.

at_hash or c_hash mismatch

Symptoms: Invalid at_hash / Invalid c_hash.

Cause: response_type returned both an authorization code and a token (hybrid flow), and the hash claims in the ID Token do not match the hash of the code or access token you received. Usually this means you reused an old authorization response, or a man-in-the-middle injected a different code.

Fix: never reuse authorization responses. Validate the hash claims when the corresponding code or access token is present in the response.

Algorithm confusion ("alg: none" or symmetric)

Symptoms: silent acceptance of a forged token if your library is misconfigured.

Cause: the library accepts whatever algorithm the JWT header declares. An attacker submits a JWT with "alg": "none" (no signature) or "alg": "HS256" (HMAC with the public key as the symmetric secret).

Fix: ALWAYS pin the expected algorithm to RS256 in your library configuration. Reject every other value. Do not trust the alg header.

Library-specific recipes

Validate a LuxID ID token (nimbus-jose-jwt + nimbus-oauth2-oidc-sdk)
JWKSet jwkSet = JWKSet.load(new URL("https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID"));
JWKSource<SecurityContext> keySource = new ImmutableJWKSet<>(jwkSet);
JWSKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource);

ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(keySelector);
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder()
.issuer("https://login.luxid.lu")
.audience(clientId)
.build(),
new HashSet<>(Arrays.asList("sub", "iat", "exp", "nonce"))));

JWTClaimsSet claims = processor.process(idToken, null);
if (!expectedNonce.equals(claims.getStringClaim("nonce"))) {
throw new BadJOSEException("Nonce mismatch");
}

Key points: pin RS256 in JWSVerificationKeySelector, use DefaultJWTClaimsVerifier for iss and aud, verify nonce explicitly.

Checklist

Before declaring an ID Token valid, confirm each of the following:

  • Signature verified with RS256 and the JWKS key whose kid matches the JWT header.
  • iss exactly matches the expected issuer for this environment.
  • aud contains your Client ID; azp (if present) equals your Client ID.
  • exp is in the future, with 5-minute clock skew tolerance.
  • nonce matches the value you sent on the authorization request.
  • Algorithm is pinned to RS256 in the library; alg: none and HS* are rejected.
  • Clock is NTP-synced.
  • Nonce is deleted from your session store after successful validation.

Cross-references

Updated 2026-05-22