Protect your Application
Overview
This page covers the application-level security controls required for a correct LuxID integration. It maps directly to the OAuth 2.0 Security Best Current Practice (RFC 9700 (opens in a new tab)), the OAuth 2.0 Threat Model (RFC 6819 (opens in a new tab)), and the OpenID Connect Core 1.0 specification.
The controls are grouped by the phase of the authentication flow where they apply:
- Transport security (HTTPS)
- Authorization request hardening (PKCE,
state,nonce) - Callback validation
- ID Token validation
- Token storage
- Anti-patterns
Meeting these controls is a prerequisite for production approval as a LuxID Partner.
HTTPS everywhere
Requirement
All redirect URIs registered with LuxID must use https://. LuxID will not redirect to http:// in production.
This is not a configuration option - LuxID rejects plain-HTTP redirect URIs for production applications at registration time.
Localhost in development
During development, http://localhost and http://127.0.0.1 are acceptable in the UAT environment only. Never register a plain-HTTP localhost URI in production.
When testing locally, prefer:
https://localhostwith a self-signed certificate (most platforms trust it automatically)- A local tunnel that provides a valid
https://URL (e.g. a development proxy)
Certificate requirements
- Use a certificate issued by a publicly trusted CA for production redirect URIs
- Ensure HSTS is configured on your application's domain
- Do not disable certificate verification in your HTTP client, even in test code - a misconfigured production deployment that inherited test settings is a recurring cause of man-in-the-middle exposure
TLS version
LuxID endpoints (login.luxid.lu) require TLS 1.2 or higher. Ensure your HTTP client library is configured to negotiate at least TLS 1.2, and prefer TLS 1.3 where available.
Always use PKCE
What PKCE does
PKCE (Proof Key for Code Exchange, RFC 7636 (opens in a new tab)) prevents Authorization Code interception attacks. Without PKCE, an attacker who intercepts the authorization code (via a redirect hijack, a referrer header, or a compromised browser extension) can exchange it for tokens.
With PKCE, the code can only be redeemed by the party that generated the original code_verifier. Even if the code is intercepted, it is useless without the verifier.
LuxID supports S256 only
LuxID supports code_challenge_method=S256. The plain method is not accepted. Always generate a cryptographically random code_verifier (minimum 43 characters, maximum 128 characters, using the unreserved character set defined in RFC 7636 §4.1).
code_verifier = high-entropy random string (43-128 chars)
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
Confidential clients must also use PKCE
RFC 9700 (opens in a new tab) §2.1.1 requires PKCE for all clients, including confidential ones (those with a client secret). A client secret proves client identity to the token endpoint, but it does not protect the authorization code in transit. Use both.
Implementation
Most OIDC client libraries generate PKCE parameters automatically. Verify that your library:
- Generates a new
code_verifierper authorization request (never reuse) - Uses
S256as the challenge method - Sends the
code_verifierin the token request body, not in a header
Validate state on every callback
What state does
The state parameter binds the authorization request to the callback. It prevents Cross-Site Request Forgery (CSRF) attacks against the OAuth flow, where an attacker tricks a user's browser into completing an authorization request initiated by someone else.
RFC 6749 (opens in a new tab) §10.12 and RFC 9700 (opens in a new tab) §2.1 both require state validation.
How to use state
- Generate a cryptographically random
statevalue before redirecting to LuxID - Store it in the user's session (server-side session or httpOnly cookie - not in localStorage)
- On callback, compare the
statereturned by LuxID to the stored value - Reject the callback immediately if the values do not match
- Python
- Java
- PHP
- Node.js
- C#
import secrets
# Before redirect
state = secrets.token_urlsafe(32)
session["oauth_state"] = state
redirect_to_luxid(state=state, ...)
# On callback
if request.params["state"] != session.pop("oauth_state"):
raise SecurityError("state mismatch - possible CSRF")
// Before redirect
String state = new BigInteger(256, new SecureRandom()).toString(16);
session.setAttribute("oauth_state", state);
redirectToLuxid(state, ...);
// On callback
if (!request.getParameter("state").equals(session.getAttribute("oauth_state"))) {
throw new SecurityException("state mismatch - possible CSRF");
}
session.removeAttribute("oauth_state");
// Before redirect
$state = bin2hex(random_bytes(32));
$_SESSION['oauth_state'] = $state;
redirect_to_luxid(['state' => $state, /* ... */]);
// On callback (constant-time compare)
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
throw new RuntimeException('state mismatch - possible CSRF');
}
unset($_SESSION['oauth_state']);
import { randomBytes } from 'crypto';
// Before redirect
const state = randomBytes(32).toString('base64url');
req.session.oauthState = state;
redirectToLuxid({ state, /* ... */ });
// On callback
if (req.query.state !== req.session.oauthState) {
throw new Error('state mismatch - possible CSRF');
}
delete req.session.oauthState;
// Before redirect
var state = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
HttpContext.Session.SetString("oauth_state", state);
RedirectToLuxid(state, /* ... */);
// On callback
if (Request.Query["state"] != HttpContext.Session.GetString("oauth_state"))
throw new SecurityException("state mismatch - possible CSRF");
HttpContext.Session.Remove("oauth_state");
state does not replace Application-level CSRF protection
state covers the OAuth callback. It does not protect the rest of your application. Use standard CSRF tokens (SameSite cookies, double-submit cookie pattern, or a CSRF header) for your application's own forms and API endpoints.
Use nonce to prevent replay attacks
What nonce does
The nonce binds the ID Token to the authorization request. Without nonce, an attacker who obtains a valid ID Token from one session could replay it in another.
How to use nonce
- Generate a cryptographically random
noncebefore redirecting to LuxID - Store it alongside
statein the server-side session - Include it in the authorization request
- After receiving the ID Token, verify that the
nonceclaim in the token matches the stored value
- Python
- Java
- PHP
- Node.js
- C#
# Before redirect
nonce = secrets.token_urlsafe(32)
session["oauth_nonce"] = nonce
redirect_to_luxid(nonce=nonce, ...)
# After token exchange
id_token_claims = decode_id_token(id_token)
if id_token_claims["nonce"] != session.pop("oauth_nonce"):
raise SecurityError("nonce mismatch - possible replay")
// Before redirect
String nonce = new BigInteger(256, new SecureRandom()).toString(16);
session.setAttribute("oauth_nonce", nonce);
redirectToLuxid(nonce, ...);
// After token exchange
JWTClaimsSet claims = decodeIdToken(idToken);
if (!claims.getStringClaim("nonce").equals(session.getAttribute("oauth_nonce"))) {
throw new SecurityException("nonce mismatch - possible replay");
}
session.removeAttribute("oauth_nonce");
// Before redirect
$nonce = bin2hex(random_bytes(32));
$_SESSION['oauth_nonce'] = $nonce;
redirect_to_luxid(['nonce' => $nonce, /* ... */]);
// After token exchange
$claims = decode_id_token($idToken);
if (!hash_equals($_SESSION['oauth_nonce'] ?? '', $claims['nonce'] ?? '')) {
throw new RuntimeException('nonce mismatch - possible replay');
}
unset($_SESSION['oauth_nonce']);
// Before redirect
const nonce = randomBytes(32).toString('base64url');
req.session.oauthNonce = nonce;
redirectToLuxid({ nonce, /* ... */ });
// After token exchange
const claims = decodeIdToken(idToken);
if (claims.nonce !== req.session.oauthNonce) {
throw new Error('nonce mismatch - possible replay');
}
delete req.session.oauthNonce;
// Before redirect
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
HttpContext.Session.SetString("oauth_nonce", nonce);
RedirectToLuxid(nonce, /* ... */);
// After token exchange
var claims = DecodeIdToken(idToken);
if (claims["nonce"] != HttpContext.Session.GetString("oauth_nonce"))
throw new SecurityException("nonce mismatch - possible replay");
HttpContext.Session.Remove("oauth_nonce");
When nonce is mandatory
nonce is mandatory for response_type=id_token (implicit flow) and highly recommended for response_type=code. LuxID issues Authorization Code flows only (response_type=code), but always include nonce in the request regardless - it adds replay protection at no cost.
ID Token validation
Every ID Token received from LuxID must be fully validated before use. Skipping validation is one of the most common and severe implementation errors in OIDC integrations.
OpenID Connect Core 1.0 §3.1.3.7 defines the mandatory checks. The five checks below are all required.
Check 1 - verify the signature
LuxID signs ID Tokens with RS256 using its current signing key. Validate the signature using the public key from the JWKS endpoint:
https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID
Use the kid header in the JWT to select the correct key from the JWKS response. See Key management for key caching and rotation guidance.
Never skip signature verification. A JWT with a tampered payload but a valid signature claim is indistinguishable from a legitimate token unless you verify the signature.
Check 2 - verify the issuer (iss)
The iss (issuer) claim must exactly match https://login.luxid.lu. Any other value means the token was not issued by LuxID.
- Python
- Java
- PHP
- Node.js
- C#
assert claims["iss"] == "https://login.luxid.lu"
if (!"https://login.luxid.lu".equals(claims.getIssuer())) {
throw new SecurityException("bad issuer");
}
if ($claims['iss'] !== 'https://login.luxid.lu') {
throw new RuntimeException('bad issuer');
}
if (claims.iss !== 'https://login.luxid.lu') {
throw new Error('bad issuer');
}
if (claims["iss"] != "https://login.luxid.lu")
throw new SecurityException("bad issuer");
For UAT integrations, the issuer is https://login-uat.luxid.lu.
Check 3 - verify the audience (aud)
The aud (audience) claim must include your application's client_id. A token intended for a different client must be rejected, even if the signature is valid.
audIf aud contains more than one value, you must also verify that the azp (authorized party) claim equals your client_id (OpenID Connect Core 1.0 §3.1.3.7, step 8). The samples below check that your client_id is present in aud; add the azp equality check if your integration may receive multi-valued audiences. See Tokens and claims.
- Python
- Java
- PHP
- Node.js
- C#
client_id = "your-client-id"
aud = claims["aud"]
if isinstance(aud, str):
assert aud == client_id
else:
assert client_id in aud
String clientId = "your-client-id";
// nimbus normalises `aud` to a list
if (!claims.getAudience().contains(clientId)) {
throw new SecurityException("audience mismatch");
}
$clientId = 'your-client-id';
$aud = $claims['aud'];
$ok = is_array($aud) ? in_array($clientId, $aud, true) : $aud === $clientId;
if (!$ok) throw new RuntimeException('audience mismatch');
const clientId = 'your-client-id';
const aud = claims.aud;
const ok = Array.isArray(aud) ? aud.includes(clientId) : aud === clientId;
if (!ok) throw new Error('audience mismatch');
const string clientId = "your-client-id";
var aud = claims["aud"];
var ok = aud is IEnumerable<string> list ? list.Contains(clientId) : (string)aud == clientId;
if (!ok) throw new SecurityException("audience mismatch");
Check 4 - verify the expiry (exp)
The exp (expiration) claim is a Unix timestamp. The current time must be before exp. Allow only a small clock skew (as small as your infrastructure allows) to account for clock drift between servers.
- Python
- Java
- PHP
- Node.js
- C#
import time
assert time.time() < claims["exp"] + 60 # 60-second skew tolerance
long now = Instant.now().getEpochSecond();
if (now >= claims.getExpirationTime().toInstant().getEpochSecond() + 60) { // 60s skew
throw new SecurityException("token expired");
}
if (time() >= $claims['exp'] + 60) { // 60-second skew tolerance
throw new RuntimeException('token expired');
}
const now = Math.floor(Date.now() / 1000);
if (now >= claims.exp + 60) { // 60-second skew tolerance
throw new Error('token expired');
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (now >= (long)claims["exp"] + 60) // 60-second skew tolerance
throw new SecurityException("token expired");
Check 5 - verify the nonce
If you sent a nonce in the authorization request (which you should always do), verify that the nonce claim in the ID Token matches the value you stored before the redirect. See §5 above.
Library validation
Most OIDC client libraries perform these checks automatically when configured correctly. Always configure the library with:
- The expected issuer (
https://login.luxid.lu) - Your
client_idas the expected audience - The JWKS URI
A current, well-maintained OIDC/JWT library from your framework's ecosystem (Spring Security, ASP.NET Core authentication, jose, oidc-client-ts, pyjwt/authlib, a SOTA PHP OIDC client, etc.) already implements all five checks above - plus edge cases such as alg confusion and JWKS rotation - according to the specifications. If you use one, you get these best practices for free. The hand-written checks on this page exist to explain what the library must enforce, not as a recommendation to re-implement them yourself.
The responsibility that stays with you is to keep that library current: pin a maintained major version, subscribe to its security advisories, and apply patch releases promptly. An out-of-date JWT/OIDC library is a recurring source of token-validation CVEs, so treat dependency updates as a security task rather than routine maintenance.
Do not disable validation options (such as verify_signature=False or verify_exp=False) in production code, even temporarily.
Token storage
Tokens are credentials. Storing them incorrectly exposes users to account takeover even if LuxID's authentication was correct.
Web Applications (server-side rendered)
- Store tokens server-side, keyed to a server-side session
- Deliver the session identifier to the browser as an
httpOnly; Secure; SameSite=Strict(orLax) cookie - Never write tokens into HTML, JavaScript variables, or response bodies that reach the browser
Single-page Applications (SPA)
SPAs are the most challenging environment because there is no server-side session by default.
Do not store tokens in localStorage or sessionStorage. These are accessible to any JavaScript running on the page, including injected scripts from XSS attacks.
Preferred approaches, in order:
- Backend-for-Frontend (BFF) pattern - the SPA calls a thin server-side component that holds the tokens. The browser gets a session cookie. This is the recommended architecture for SPAs handling sensitive data.
- Refresh tokens via
prompt=none- for SPAs that cannot use a BFF, request a new short-lived access token silently via a hidden iframe usingprompt=none. This avoids storing long-lived refresh tokens in browser storage. Note this depends on the LuxID session cookie being present in the iframe - it may not work in all browsers due to third-party cookie restrictions. - In-memory storage - store tokens in JavaScript variables (not
windowproperties). Tokens are lost on page reload, requiring re-authentication. Acceptable for very short sessions.
Do not store refresh tokens in browser storage (localStorage, sessionStorage, IndexedDB) unless the application is designed with that risk explicitly understood and mitigated.
Mobile Applications
| Platform | Recommended Storage |
|---|---|
| iOS | Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly) |
| Android | Android Keystore + EncryptedSharedPreferences |
Never store tokens in:
- Plain
SharedPreferences(Android) - unencrypted on non-rooted devices, readable on rooted ones NSUserDefaults(iOS) - unencrypted- Local files without encryption
For mobile, follow RFC 8252 (opens in a new tab) (OAuth 2.0 for Native Apps), which specifies the use of system browser (ASWebAuthenticationSession / Chrome Custom Tabs) rather than embedded WebViews for the authorization flow.
Desktop Applications
Use the OS credential vault:
- Windows: Windows Credential Manager (via
Windows.Security.Credentials.PasswordVaultorDPAPI) - macOS: macOS Keychain
- Linux:
libsecret(Secret Service API, backed by GNOME Keyring or KWallet)
Backend services
Store tokens in an encrypted secrets store:
- HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, or equivalent
- Do not write tokens to disk in plaintext
- Do not log tokens (access logs, debug logs, error logs)
Summary table
| Context | Recommended | Never |
|---|---|---|
| Server-side web | Server-side session + httpOnly cookie | Browser storage, response body |
| SPA | BFF pattern or in-memory | localStorage, sessionStorage |
| iOS | Keychain | NSUserDefaults, plaintext files |
| Android | Keystore + EncryptedSharedPreferences | SharedPreferences, plaintext |
| Desktop | OS credential vault | Plaintext files, registry |
| Backend | Encrypted secrets store | Disk plaintext, environment variables in logs |
Open redirect prevention
How LuxID prevents open redirects
LuxID enforces exact-match validation of redirect_uri against the list of URIs registered for your Application. It does not perform prefix matching or pattern matching.
This means an attacker cannot construct an authorization URL with a modified redirect_uri that points to their server. The authorization request will be rejected with an error before the user is shown any login UI.
Registering only the URIs your application actually uses is therefore a security control, not just a configuration step. Keep the registered URI list minimal.
Wildcard URIs
LuxID does not permit wildcard redirect URIs (e.g. https://example.com/*). Each redirect URI must be specified exactly.
POST-login redirect within your Application
Once the callback is received and validated, your application may redirect the user to a post-login destination (e.g. the page they originally requested). Validate this destination URI server-side against an allowlist. Do not trust a returnTo query parameter that arrived with the original request without sanitising it - it can be attacker-controlled.
Client secret management
What a client secret is
A client_secret is a credential that authenticates your application (the OAuth client) to LuxID's token endpoint. LuxID uses it to verify that the party redeeming an authorization code is the registered application, not an impersonator.
Never embed secrets in public code
Client secrets must never appear in:
- Mobile application binaries (APK, IPA) - they can be extracted by static analysis
- SPA JavaScript - they are visible in browser dev tools and source maps
- Public Git repositories - even private repositories are a risk if access controls change
- Build logs or CI/CD output
For SPAs and mobile applications, use PKCE without a client secret (public client). The client_secret is a confidential-client mechanism only.
Rotation
- Rotate client secrets on a regular schedule (at minimum annually, more frequently for high-risk applications)
- Rotate immediately following any suspected exposure
- LuxID supports issuing a new secret while keeping the old one active for a brief overlap period to allow zero-downtime rotation
See Client credentials for rotation steps.
Storage in backend Applications
Store client secrets in an encrypted secrets store or environment variable injected at runtime - never hardcoded in source. See §7.5 above.
Token anti-patterns
The following practices have caused security incidents in real LuxID Partner integrations. Avoid them.
Tokens in URLs
Never put tokens (access tokens, ID tokens, refresh tokens) in URL query parameters. URLs are logged by web servers, proxies, CDNs, and browser history. A token in a URL is a token in every log file that handled that request.
The Authorization Code flow returns a code in the URL, not a token - this is by design. Exchange the code immediately on the server side. Do not pass the code around between services.
Tokens in logs
Never log token values. This includes:
- Application debug logs
- Error reporting tools (Sentry, Datadog, etc.)
- Analytics platforms
- Structured logging pipelines
If you need to log token-related events for debugging, log the sub claim (a stable, non-sensitive user identifier) and the token type, not the token itself.
Not logging the token does not mean logging nothing. You should log the non-sensitive outcome of each LuxID exchange so you can diagnose problems and feed a log analyzer or SIEM: the HTTP status, the OAuth/OIDC error and error_description, the sub, the Global Transaction ID from the LuxID HTTP response headers (the identifier your own code can capture automatically - see the identifiers reference), a timestamp, and which environment and client_id were used. These let you (and LuxID support) spot a rising invalid_grant or redirect_uri_mismatch rate before users complain - without ever storing a credential. See OAuth and OIDC error codes and the Error reference for what each code means and how to act on it.
Tokens in analytics
Do not include tokens or token-derived PII in analytics events sent to third-party platforms. The sub claim is pseudonymous and may be used for analytics if your privacy policy and data processing agreements cover it, but the token itself must not be forwarded.
Reusing tokens beyond their intended audience
An access token is intended for the resource server (API) specified in its aud claim. Do not forward it to a different service or use it as a session token in your application. Use the ID Token for identity, and issue your own session credentials for your application.
Ignoring token expiry
Do not cache access tokens without checking their exp claim before use. An expired token will be rejected by the API it is sent to. Implement proactive refresh: renew the access token before it expires rather than waiting for an API error.
Hardcoded JWKS keys
Never hardcode the LuxID signing public key. LuxID rotates signing keys periodically. An application that hardcodes the key will break silently after rotation. Always fetch from the JWKS endpoint and cache with a TTL. See Key management.
CSRF protection beyond the OAuth flow
state covers CSRF in the OAuth callback. Your application needs its own CSRF protection for authenticated requests after the session is established.
Recommended approaches:
- SameSite cookies: set
SameSite=StrictorSameSite=Laxon your session cookie. This prevents cross-site requests from carrying the session cookie in most browsers - CSRF tokens: include a per-session CSRF token in every state-mutating form and verify it server-side
- Custom request headers: APIs called by SPAs can require a custom header (e.g.
X-Requested-With: XMLHttpRequest) which simple cross-origin requests cannot send
Do not rely solely on checking the Referer or Origin header - these can be absent or spoofed in some environments.
Security checklist
Use this checklist during integration review and before production go-live.
| Control | Required | Notes |
|---|---|---|
| All redirect URIs use HTTPS | Yes | Enforced by LuxID at registration in production |
| PKCE S256 used on every flow | Yes | Including confidential clients (RFC 9700) |
state generated and validated per request | Yes | Cryptographically random, stored server-side |
nonce generated and validated per request | Yes | Cryptographically random, stored server-side |
| ID Token signature verified via JWKS | Yes | Never skip; never hardcode the key |
ID Token iss checked | Yes | Must be https://login.luxid.lu |
ID Token aud checked | Yes | Must include your client_id |
ID Token exp checked | Yes | Allow only a small clock skew |
ID Token nonce checked | Yes | Must match stored value |
| Tokens not stored in localStorage (SPA) | Yes | Use BFF pattern or in-memory |
| Tokens not stored in plain SharedPrefs (Android) | Yes | Use Keystore + EncryptedSharedPreferences |
| Tokens not in URLs | Yes | No token query parameters |
| Tokens not in logs | Yes | Log sub, not tokens |
| Client secret not in mobile/SPA code | Yes | Public client for mobile/SPA |
| Client secret stored in encrypted store | Yes | Backend only |
| Client secret rotation schedule defined | Yes | At minimum annually |
| JWKS fetched dynamically with TTL cache | Yes | Never hardcoded |
| Post-login redirect validated against allowlist | Yes | Prevent open redirect within app |
| Application-level CSRF protection in place | Yes | SameSite cookies and/or CSRF tokens |
Further reading
- RFC 9700 - OAuth 2.0 Security Best Current Practice (opens in a new tab)
- RFC 6819 - OAuth 2.0 Threat Model and Security Considerations (opens in a new tab)
- RFC 7636 - PKCE (opens in a new tab)
- RFC 8252 - OAuth 2.0 for Native Apps (opens in a new tab)
- OpenID Connect Core 1.0 §3.1.3.7 - ID Token Validation (opens in a new tab)
- OWASP Top 10 (opens in a new tab)
- Integration security checklist - a walkable self-test split into what you verify vs what LuxID enforces, with responsible-disclosure guidance
- Session management - token persistence and refresh lifecycle
- Key management - JWKS, signing keys, rotation
- Token validation issues - debugging common validation failures