Skip to main content
Version 0.4Draft

Common errors

This page covers the ten integration mistakes that account for the large majority of LuxID support requests. Each entry shows the exact error you will observe, explains the root cause, and gives a concrete fix.

For the full catalogue of OAuth and OIDC error codes, see OAuth and OIDC error codes.


App re-prompts for credentials and OTP after every restart

What you observe

Your application sends users through the full authentication flow - including MFA - every time the application restarts or the server process recycles. Existing authenticated sessions are not recognised.

Root cause

Refresh tokens (and the session state associated with them) are held in memory only. When the process restarts, all tokens are lost. The application then has no valid session and must redirect the user to LuxID for a fresh login.

Fix

Persist refresh tokens in durable storage (a database, an encrypted file, a secrets manager). Restore the token on startup and use it to obtain a new access token silently via the token endpoint before falling back to an interactive login.

POST https://login.luxid.lu/mga/sps/oauth/oauth20/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<stored_refresh_token>
&client_id=<your_client_id>
&client_secret=<your_client_secret>

If the refresh token has expired or been revoked, the token endpoint returns invalid_grant - handle this by redirecting the user to the authorisation endpoint for a fresh login. See Session management for full guidance on token lifecycle and storage.


redirect_uri_mismatch on the authorisation callback

What you observe

An unregistered redirect_uri is never redirected to (LuxID will not send a response to a URI it does not trust). Instead the user is shown the LuxID error page. If your code reaches the token endpoint with a mismatched redirect_uri, LuxID returns HTTP 400 with:

{
"error": "invalid_request",
"error_description": "FBTOAU210E The redirection URI provided in the request is either invalid, or does not meet matching criteria against the registered redirection URI."
}

Root cause

LuxID enforces exact-match redirect URIs. The redirect_uri in your authorisation request does not match any registered URI character-for-character. The most common mismatches are:

  • A trailing slash present in one place but not the other (/callback vs /callback/)
  • http in the request against https registered in the Console (or vice versa)
  • A port number difference (localhost:3000 vs localhost:3001)
  • A path difference (/callback vs /auth/callback)
  • URL encoding differences (%2F vs /)

Fix

Open the LuxID Console, navigate to your Application, and compare the registered redirect URIs against the exact value your application sends in the redirect_uri parameter. They must match byte-for-byte. See Redirect URIs and domains and Redirect and domain issues for the full list of mismatch patterns.


state mismatch on callback - CSRF check fails

What you observe

Your application rejects the authorisation callback because the state value returned by LuxID does not match the one your application stored before the redirect. Depending on your library, you may see an exception, a generic error page, or a log entry such as:

State mismatch: expected=abc123 received=xyz789

Or the state parameter is absent entirely from the callback.

Root cause

The state value generated at the start of the authorisation flow was not stored in a durable, per-user location. Common causes:

  • The value was stored in a server-side session, but the session expired between the authorisation request and the callback (especially with long login flows or slow MFA)
  • In a multi-server environment, the callback was handled by a different server instance than the one that started the flow, and sessions are not shared
  • The value was stored only in a cookie that was not sent back on the callback request (SameSite policy, third-party context, or cookie domain mismatch)

Fix

Generate a cryptographically random state value (at least 128 bits of entropy). Store it in a short-lived server-side session or a signed, encrypted cookie keyed to the user's browser session. Validate the returned state on the callback before processing any authorisation code. If sessions are not sticky across servers, use a shared session store (Redis, a database) rather than in-memory storage.

import secrets
state = secrets.token_urlsafe(32)
session["oauth_state"] = state
# ... redirect to authorisation endpoint with state=state
# ... on callback:
if request.args.get("state") != session.pop("oauth_state", None):
raise ValueError("State mismatch - possible CSRF")

nonce mismatch when validating the ID Token

What you observe

Your application or OIDC library rejects the ID token with an error such as:

ID token nonce mismatch: expected=abc123 got=xyz789

Or the nonce claim is absent from the ID token when your application expects it.

Root cause

The nonce value included in the authorisation request is bound to the specific flow instance. A mismatch means one of:

  • The nonce was not stored alongside the state and was lost before the callback
  • An old authorisation response (with a different nonce) was replayed - for example, by navigating back in the browser to a previously completed callback URL
  • The nonce was generated but not sent in the authorisation request, so LuxID did not include it in the ID token

Fix

Generate a unique nonce for every authorisation request (same entropy requirements as state). Store it in the session alongside the state. Validate the nonce claim in the ID token on every callback. Reject any ID token whose nonce does not match what was sent. Never reuse a nonce across requests.

nonce = secrets.token_urlsafe(32)
session["oauth_nonce"] = nonce
# include nonce=nonce in the authorisation request
# on callback, after receiving the ID token:
if id_token_claims["nonce"] != session.pop("oauth_nonce", None):
raise ValueError("Nonce mismatch - possible replay attack")

ID Token signature verification fails

What you observe

Your application's JWT validation library rejects the ID token with an error such as:

Signature verification failed
kid not found in JWKS
Unknown key ID: abc123def456

The kid (Key ID) in the ID token header does not match any key currently in your cached JWKS.

Root cause

LuxID rotates its signing keys periodically. Your application cached the JWKS at startup (or at some earlier point) and has not refreshed it since. The token was signed with a new key that is not in your cache.

Fix

When the JWT library reports "kid not found", immediately fetch the JWKS again from https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID and retry validation with the refreshed key set. Do not simply reject the token without attempting a refresh first. Cache the JWKS with a TTL (recommended: 1 hour), but always trigger an immediate refresh on a kid miss.

async function getKey(header) {
let key = keyCache.get(header.kid);
if (!key) {
await refreshJwks(); // fetch from JWKS endpoint
key = keyCache.get(header.kid);
}
if (!key) throw new Error("Unknown signing key: " + header.kid);
return key;
}

See Key management and Token validation issues for full JWKS caching guidance.


invalid_client from the token endpoint

What you observe

The token endpoint returns HTTP 400 with invalid_client. The exact error_description depends on the cause:

{
"error": "invalid_client",
"error_description": "FBTOAU203E The client identifier could not be found."
}

Other observed error_description values for invalid_client:

  • FBTOAU229E Confidential clients accessing the token endpoint must authenticate using their registered credentials. - a confidential client sent no client authentication.
  • FBTOAU204E An invalid client assertion or client secret was provided for the client identifier. - the client secret is wrong.

Root cause

The most common cause is an environment mismatch: a UAT Client ID (or client secret) sent to the production token endpoint, or vice versa. Other causes include:

  • The client secret has been rotated in the Console but the application is still using the old value
  • The client_id was copied with leading or trailing whitespace
  • The application is sending client_id and client_secret in the request body, but the application is registered as a confidential client expecting HTTP Basic authentication (or the reverse)

Fix

Verify that the client_id and client_secret your application sends match exactly what is shown with LuxID for the correct environment. UAT credentials must be used against https://login-uat.luxid.lu endpoints only; production credentials against https://login.luxid.lu only.

UAT token endpoint: https://login-uat.luxid.lu/mga/sps/oauth/oauth20/token
Prod token endpoint: https://login.luxid.lu/mga/sps/oauth/oauth20/token

If the secret was recently rotated, update the value in your secret store and redeploy.


What you observe

Users are presented with the LuxID consent screen every time they log in to your application, even though they previously consented. The flow works correctly, but the repeated consent prompt creates friction.

Root cause

This is expected behaviour when the set of requested scopes or claims changes. LuxID stores consent per user, per application, per scope set. If your application requests a scope or claim that the user has not previously consented to - even one additional claim - LuxID will show the consent screen again.

This also occurs if:

Fix

Review the scopes and claims your application requests and ensure they are stable. Do not request scopes you do not use. If you are adding new scopes, expect users to be prompted once. If you are not adding scopes and consent is still shown every time, check whether your authorisation request inadvertently includes prompt=consent. See Tokens and claims for guidance on scope selection.


Refresh Token returns invalid_grant after a few weeks

What you observe

A refresh token that worked correctly begins returning:

{
"error": "invalid_grant",
"error_description": "FBTOAU211E The [authorization_grant] received of type [refresh_token] does not exist."
}

This typically appears weeks after the initial authentication, with no apparent change on the application side.

Root cause

There are two distinct causes:

Cause A - User revocation. The user visited https://account.luxid.lu/ (opens in a new tab) and revoked your application's access under Applications. This immediately invalidates all refresh tokens for that user-application pair. The application has no advance notice.

Cause B - Rotation not handled. LuxID uses refresh token rotation: each time a refresh token is used, a new refresh token is issued and the old one is invalidated. If your application stored the original refresh token and then used it again after a rotation cycle, the stored token is no longer valid.

Fix

For rotation (Cause B): Every time you call the token endpoint with grant_type=refresh_token, store the new refresh_token from the response immediately, replacing the old one. Never reuse a refresh token after it has been exchanged.

For revocation (Cause A): When the token endpoint returns invalid_grant on a refresh attempt, treat this as a signal to initiate an interactive login. Redirect the user to the authorisation endpoint. Do not retry the refresh with the same token.

try:
tokens = exchange_refresh_token(stored_refresh_token)
store_refresh_token(tokens["refresh_token"]) # always update
except InvalidGrantError:
redirect_to_login() # user must authenticate interactively

See Session management for the full refresh token lifecycle.


UserInfo endpoint returns HTTP 401

What you observe

A GET request to https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo returns:

{
"error": "invalid_token",
"error_description": "FBTOAU211E The [access_token] received of type [bearer] does not exist."
}

Or:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="luxid", error="invalid_token",
error_description="The access token expired"

Root cause

The access token presented in the Authorization: Bearer header has expired or been revoked. LuxID access tokens have a short lifetime (typically minutes to hours, depending on your application's configuration). An access token cannot be refreshed directly - a new one must be obtained using the refresh token.

Other causes:

  • The Authorization header is malformed (e.g. Bearer capitalisation is wrong, or there is extra whitespace)
  • The access token was issued by the UAT environment but is being sent to the production UserInfo endpoint, or vice versa

Fix

Before calling the UserInfo endpoint, check whether the access token has expired (inspect the exp claim in the JWT or track the expires_in value returned by the token endpoint). If expired, use the refresh token to obtain a new access token, then retry the UserInfo request.

if is_expired(access_token):
tokens = exchange_refresh_token(refresh_token)
access_token = tokens["access_token"]

response = requests.get(
"https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo",
headers={"Authorization": f"Bearer {access_token}"}
)

SAML assertion rejected due to clock skew

What you observe

Your SAML Service Provider (SP) rejects the SAML assertion from LuxID with an error such as:

The assertion is not yet valid
NotBefore condition not met: 2026-05-22T10:00:05Z > 2026-05-22T09:59:55Z

Or the assertion is rejected as expired despite having just been issued.

Root cause

SAML assertions carry NotBefore and NotOnOrAfter timestamps. If the SP's system clock is skewed relative to LuxID's clock by more than the tolerance configured in the SP, it will reject valid assertions. A skew of even 30 seconds can trigger this in strictly configured SPs.

Fix

  1. Synchronise the SP clock. Ensure the SP server is synchronised via NTP. A correctly synchronised server should have sub-second skew relative to LuxID.

  2. Configure a clock skew tolerance. Most SAML libraries allow a configurable tolerance (commonly called clockSkew, allowedClockSkew, or maxClockSkew). Set this to at least 5 minutes (300 seconds) to account for network latency and minor synchronisation drift.

  3. Verify LuxID's clock. If you believe LuxID's clock is skewed, include the assertion XML in your support ticket so the team can verify the timestamps. In practice this is very rare - LuxID's production servers are NTP-synchronised.

Example assertion timestamps
<!-- Example assertion timestamps that would fail with 0-second tolerance
if the SP clock is 15 seconds behind LuxID -->
<Conditions NotBefore="2026-05-22T10:00:00Z"
NotOnOrAfter="2026-05-22T10:05:00Z">

FEATURE_DISABLED (HTTP 403)

What you observe

An API call or login flow step returns HTTP 403 Forbidden with:

{
"type": "FEATURE_DISABLED",
"message": "Feature disabled"
}

Root cause

LuxID uses hierarchical feature flags internally to ship features incrementally and toggle them on or off without redeploying. When a request targets an API path or flow step that corresponds to a disabled feature, LuxID returns this error immediately - no credential or authorisation check takes place.

Feature flags are hierarchical: disabling a parent feature (e.g. passkey) simultaneously disables all children (passkey.add, passkey.delete, passkey.rename, passkey.login, and so on). A single FEATURE_DISABLED symptom can therefore affect several related operations at once.

This typically appears in one of the following situations:

  • A feature rollout where the new feature is enabled in UAT only and not yet in production.
  • A temporary disablement for maintenance or a hot-fix.
  • A regional or Partner-scoped rollout where the feature is enabled only for specific Partners.

Fix

  • Confirm with LuxID whether the feature is available in the environment you are targeting.
  • Do not retry - retrying returns the same error. Disabled features remain disabled until LuxID toggles them on.
  • Plan your release timing around the feature flag schedule announced via the Partner channel.
  • If you are testing in UAT and the feature works there but not in production, it is in staged rollout - await the production enablement announcement.

LuxID Application-level error codes

In addition to OAuth/OIDC protocol errors, LuxID may surface application-level error codes within the login UI or via API responses. The table below lists the most relevant codes.

Error codeTypical contextMeaning
ACCOUNT_NOT_ACTIVATEDLoginThe account exists but the user has not completed email verification
ACCOUNT_NOT_FOUNDPassword resetNo account exists for the supplied email address
WRONG_LOGIN_OR_PASSWORDLogin, password changeThe email/password combination is incorrect - generic message to prevent enumeration
INVALID_OTPMFA stepThe one-time code entered is incorrect
INVALID_OTP_MAX_RETRIESMFA stepThe user has exceeded the maximum number of OTP attempts
INVALID_TOKENMFA, email validationThe token or code has been used already or is structurally invalid
TOKEN_EXPIREDEmail validation, password resetThe token is structurally valid but its validity window has passed
EMAIL_ALREADY_EXISTSRegistration, email changeThe email address is already associated with a different LuxID Account
INVALID_SESSIONLogin page loadThe login session is no longer valid; typically caused by navigating back after completing a flow
FLOOD_PROTECTION_TRIGGEREDLogin, OTPRate limiting has been applied due to repeated failed attempts
NO_PASSKEY_AVAILABLEPasskey loginThe user has no passkeys registered and passkey-only authentication was requested
BAD_REQUESTVariousThe request is malformed; check the error_description for the specific attribute that failed validation

These codes appear in the user-facing login UI and are not returned directly to your application via OAuth callbacks. If a user reports an error and you need to correlate it with a specific code, check the audit log with LuxID. See Logs and audit trails.

Updated 2026-07-02