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 (
/callbackvs/callback/) httpin the request againsthttpsregistered in the Console (or vice versa)- A port number difference (
localhost:3000vslocalhost:3001) - A path difference (
/callbackvs/auth/callback) - URL encoding differences (
%2Fvs/)
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.
- Python
- Java
- PHP
- Node.js
- C#
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")
String state = new BigInteger(256, new SecureRandom()).toString(16);
session.setAttribute("oauth_state", state);
// ... redirect to the authorisation endpoint with state=state
// ... on callback:
if (!Objects.equals(request.getParameter("state"), session.getAttribute("oauth_state"))) {
throw new IllegalStateException("State mismatch - possible CSRF");
}
session.removeAttribute("oauth_state");
$state = bin2hex(random_bytes(32));
$_SESSION['oauth_state'] = $state;
// ... redirect to the authorisation endpoint with state=state
// ... on callback:
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
throw new RuntimeException('State mismatch - possible CSRF');
}
unset($_SESSION['oauth_state']);
const state = randomBytes(32).toString('base64url');
req.session.oauthState = state;
// ... redirect to the authorisation endpoint with state=state
// ... on callback:
if (req.query.state !== req.session.oauthState) {
throw new Error('State mismatch - possible CSRF');
}
delete req.session.oauthState;
var state = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
HttpContext.Session.SetString("oauth_state", state);
// ... redirect to the authorisation endpoint with state=state
// ... on callback:
if (Request.Query["state"] != HttpContext.Session.GetString("oauth_state"))
throw new InvalidOperationException("State mismatch - possible CSRF");
HttpContext.Session.Remove("oauth_state");
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
stateand 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.
- Python
- Java
- PHP
- Node.js
- C#
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")
String nonce = new BigInteger(256, new SecureRandom()).toString(16);
session.setAttribute("oauth_nonce", nonce);
// include nonce=nonce in the authorisation request
// on callback, after receiving the ID token:
if (!Objects.equals(idTokenClaims.getStringClaim("nonce"), session.getAttribute("oauth_nonce"))) {
throw new IllegalStateException("Nonce mismatch - possible replay attack");
}
session.removeAttribute("oauth_nonce");
$nonce = bin2hex(random_bytes(32));
$_SESSION['oauth_nonce'] = $nonce;
// include nonce=nonce in the authorisation request
// on callback, after receiving the ID token:
if (!hash_equals($_SESSION['oauth_nonce'] ?? '', $idTokenClaims['nonce'] ?? '')) {
throw new RuntimeException('Nonce mismatch - possible replay attack');
}
unset($_SESSION['oauth_nonce']);
const nonce = randomBytes(32).toString('base64url');
req.session.oauthNonce = nonce;
// include nonce=nonce in the authorisation request
// on callback, after receiving the ID token:
if (idTokenClaims.nonce !== req.session.oauthNonce) {
throw new Error('Nonce mismatch - possible replay attack');
}
delete req.session.oauthNonce;
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
HttpContext.Session.SetString("oauth_nonce", nonce);
// include nonce=nonce in the authorisation request
// on callback, after receiving the ID token:
if (idTokenClaims["nonce"] != HttpContext.Session.GetString("oauth_nonce"))
throw new InvalidOperationException("Nonce mismatch - possible replay attack");
HttpContext.Session.Remove("oauth_nonce");
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_idwas copied with leading or trailing whitespace - The application is sending
client_idandclient_secretin 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.
Consent screen shown on every login
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:
- The application was re-registered or its consent records were cleared
- The user manually revoked consent from their account at https://account.luxid.lu/ (opens in a new tab)
- The application requests
prompt=consentexplicitly
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.
- Python
- Java
- PHP
- Node.js
- C#
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
try {
Tokens tokens = exchangeRefreshToken(storedRefreshToken);
storeRefreshToken(tokens.getRefreshToken()); // always update
} catch (InvalidGrantException e) {
redirectToLogin(); // user must authenticate interactively
}
try {
$tokens = exchange_refresh_token($storedRefreshToken);
store_refresh_token($tokens['refresh_token']); // always update
} catch (InvalidGrantException $e) {
redirect_to_login(); // user must authenticate interactively
}
try {
const tokens = await exchangeRefreshToken(storedRefreshToken);
await storeRefreshToken(tokens.refresh_token); // always update
} catch (err) {
if (!(err instanceof InvalidGrantError)) throw err;
redirectToLogin(); // user must authenticate interactively
}
try
{
var tokens = await ExchangeRefreshTokenAsync(storedRefreshToken);
await StoreRefreshTokenAsync(tokens.RefreshToken); // always update
}
catch (InvalidGrantException)
{
RedirectToLogin(); // 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
Authorizationheader is malformed (e.g.Bearercapitalisation 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.
- Python
- Java
- PHP
- Node.js
- C#
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}"}
)
if (isExpired(accessToken)) {
Tokens tokens = exchangeRefreshToken(refreshToken);
accessToken = tokens.getAccessToken();
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo"))
.header("Authorization", "Bearer " + accessToken)
.GET().build();
HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
if (is_expired($accessToken)) {
$tokens = exchange_refresh_token($refreshToken);
$accessToken = $tokens['access_token'];
}
$ch = curl_init('https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo');
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$accessToken}"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if (isExpired(accessToken)) {
const tokens = await exchangeRefreshToken(refreshToken);
accessToken = tokens.access_token;
}
const response = await fetch(
'https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo',
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (IsExpired(accessToken))
{
var tokens = await ExchangeRefreshTokenAsync(refreshToken);
accessToken = tokens.AccessToken;
}
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.GetAsync("https://login.luxid.lu/mga/sps/oauth/oauth20/userinfo");
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
-
Synchronise the SP clock. Ensure the SP server is synchronised via NTP. A correctly synchronised server should have sub-second skew relative to LuxID.
-
Configure a clock skew tolerance. Most SAML libraries allow a configurable tolerance (commonly called
clockSkew,allowedClockSkew, ormaxClockSkew). Set this to at least 5 minutes (300 seconds) to account for network latency and minor synchronisation drift. -
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 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 code | Typical context | Meaning |
|---|---|---|
ACCOUNT_NOT_ACTIVATED | Login | The account exists but the user has not completed email verification |
ACCOUNT_NOT_FOUND | Password reset | No account exists for the supplied email address |
WRONG_LOGIN_OR_PASSWORD | Login, password change | The email/password combination is incorrect - generic message to prevent enumeration |
INVALID_OTP | MFA step | The one-time code entered is incorrect |
INVALID_OTP_MAX_RETRIES | MFA step | The user has exceeded the maximum number of OTP attempts |
INVALID_TOKEN | MFA, email validation | The token or code has been used already or is structurally invalid |
TOKEN_EXPIRED | Email validation, password reset | The token is structurally valid but its validity window has passed |
EMAIL_ALREADY_EXISTS | Registration, email change | The email address is already associated with a different LuxID Account |
INVALID_SESSION | Login page load | The login session is no longer valid; typically caused by navigating back after completing a flow |
FLOOD_PROTECTION_TRIGGERED | Login, OTP | Rate limiting has been applied due to repeated failed attempts |
NO_PASSKEY_AVAILABLE | Passkey login | The user has no passkeys registered and passkey-only authentication was requested |
BAD_REQUEST | Various | The 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.