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.
- Signature. Verify the JWS signature using RS256 and the LuxID public key whose
kidmatches the JWT header. Fetch the key from the LuxID JWKS endpoint (opens in a new tab) (UAT: replace host). - Issuer.
issMUST equalhttps://login.luxid.lu(orhttps://login-uat.luxid.luin UAT). Exact match, no trailing slash tolerance. - Audience.
audMUST contain your Application's Client ID. Ifaudis a JSON array, your Client ID must be present andazp(when present) must equal your Client ID. - Expiry.
expMUST be in the future, with up to 60 seconds of clock skew tolerance. - Nonce. If you sent
nonceon the authorization request (you should), thenonceclaim 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
- Java
- Node.js
- Python
- Go
- .NET
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.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID'),
{ cacheMaxAge: 600000 }
);
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://login.luxid.lu',
audience: process.env.LUXID_CLIENT_ID,
algorithms: ['RS256'],
clockTolerance: '5m'
});
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch');
}
Key points: pin algorithms: ['RS256'], set clockTolerance, verify nonce after jwtVerify.
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
payload = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=client_id,
issuer="https://login.luxid.lu",
leeway=60,
options={"require": ["iss", "aud", "exp", "iat", "sub"]}
)
if payload.get("nonce") != expected_nonce:
raise ValueError("Nonce mismatch")
Key points: pin algorithms=["RS256"], set leeway=60 (60 seconds), verify nonce.
keySet, _ := jwk.Fetch(ctx, "https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID")
token, err := jwt.Parse(idToken, func(t *jwt.Token) (interface{}, error) {
if t.Method.Alg() != "RS256" {
return nil, fmt.Errorf("unexpected alg: %v", t.Method.Alg())
}
kid, _ := t.Header["kid"].(string)
key, ok := keySet.LookupKeyID(kid)
if !ok {
return nil, fmt.Errorf("kid %q not in JWKS", kid)
}
var pub interface{}
if err := key.Raw(&pub); err != nil {
return nil, err
}
return pub, nil
})
claims := token.Claims.(jwt.MapClaims)
if claims["iss"] != "https://login.luxid.lu" { /* reject */ }
if !claims.VerifyAudience(clientID, true) { /* reject */ }
if !claims.VerifyExpiresAt(time.Now().Add(-5*time.Minute).Unix(), true) { /* reject */ }
if claims["nonce"] != expectedNonce { /* reject */ }
Key points: explicit alg check in the keyfunc, explicit iss/aud/exp/nonce checks after parsing.
var validationParameters = new TokenValidationParameters
{
ValidIssuer = "https://login.luxid.lu",
ValidAudience = clientId,
ValidAlgorithms = new[] { "RS256" },
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
jwksCache.GetKeysByKid(kid),
ClockSkew = TimeSpan.FromMinutes(5),
RequireExpirationTime = true,
RequireSignedTokens = true
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, validationParameters, out var validatedToken);
var nonce = principal.FindFirst("nonce")?.Value;
if (nonce != expectedNonce) { /* reject */ }
Key points: ValidAlgorithms = new[] { "RS256" } (NEVER omit this), RequireSignedTokens = true.
Checklist
Before declaring an ID Token valid, confirm each of the following:
- Signature verified with RS256 and the JWKS key whose
kidmatches the JWT header. -
issexactly matches the expected issuer for this environment. -
audcontains your Client ID;azp(if present) equals your Client ID. -
expis in the future, with 5-minute clock skew tolerance. -
noncematches the value you sent on the authorization request. - Algorithm is pinned to RS256 in the library;
alg: noneand HS* are rejected. - Clock is NTP-synced.
- Nonce is deleted from your session store after successful validation.
Cross-references
- Tokens and claims - the claim catalogue and what each claim means.
- Key management - JWKS caching, rotation, and
kidlookup patterns. - Protect your Application - PKCE, state, nonce, and CSRF mitigations.
- OAuth and OIDC error codes - the error responses LuxID returns when the upstream request itself is malformed.