Skip to main content
Version 0.3Draft

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:

  1. Transport security (HTTPS)
  2. Authorization request hardening (PKCE, state, nonce)
  3. Callback validation
  4. ID Token validation
  5. Token storage
  6. 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://localhost with 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_verifier per authorization request (never reuse)
  • Uses S256 as the challenge method
  • Sends the code_verifier in 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

  1. Generate a cryptographically random state value before redirecting to LuxID
  2. Store it in the user's session (server-side session or httpOnly cookie - not in localStorage)
  3. On callback, compare the state returned by LuxID to the stored value
  4. Reject the callback immediately if the values do not match
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")

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

  1. Generate a cryptographically random nonce before redirecting to LuxID
  2. Store it alongside state in the server-side session
  3. Include it in the authorization request
  4. After receiving the ID Token, verify that the nonce claim in the token matches the stored value
# 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")

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.

danger

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.

assert claims["iss"] == "https://login.luxid.lu"

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.

Multi-valued aud

If 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.

client_id = "your-client-id"
aud = claims["aud"]
if isinstance(aud, str):
assert aud == client_id
else:
assert client_id in aud

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.

import time
assert time.time() < claims["exp"] + 60 # 60-second skew tolerance

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_id as the expected audience
  • The JWKS URI
Prefer a maintained library over hand-rolled checks

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.

caution

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 (or Lax) 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.

danger

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:

  1. 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.
  2. Refresh tokens via prompt=none - for SPAs that cannot use a BFF, request a new short-lived access token silently via a hidden iframe using prompt=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.
  3. In-memory storage - store tokens in JavaScript variables (not window properties). 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

PlatformRecommended Storage
iOSKeychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)
AndroidAndroid 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.PasswordVault or DPAPI)
  • 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

ContextRecommendedNever
Server-side webServer-side session + httpOnly cookieBrowser storage, response body
SPABFF pattern or in-memorylocalStorage, sessionStorage
iOSKeychainNSUserDefaults, plaintext files
AndroidKeystore + EncryptedSharedPreferencesSharedPreferences, plaintext
DesktopOS credential vaultPlaintext files, registry
BackendEncrypted secrets storeDisk 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.

Do log the outcome, just not the token

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=Strict or SameSite=Lax on 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.

ControlRequiredNotes
All redirect URIs use HTTPSYesEnforced by LuxID at registration in production
PKCE S256 used on every flowYesIncluding confidential clients (RFC 9700)
state generated and validated per requestYesCryptographically random, stored server-side
nonce generated and validated per requestYesCryptographically random, stored server-side
ID Token signature verified via JWKSYesNever skip; never hardcode the key
ID Token iss checkedYesMust be https://login.luxid.lu
ID Token aud checkedYesMust include your client_id
ID Token exp checkedYesAllow only a small clock skew
ID Token nonce checkedYesMust match stored value
Tokens not stored in localStorage (SPA)YesUse BFF pattern or in-memory
Tokens not stored in plain SharedPrefs (Android)YesUse Keystore + EncryptedSharedPreferences
Tokens not in URLsYesNo token query parameters
Tokens not in logsYesLog sub, not tokens
Client secret not in mobile/SPA codeYesPublic client for mobile/SPA
Client secret stored in encrypted storeYesBackend only
Client secret rotation schedule definedYesAt minimum annually
JWKS fetched dynamically with TTL cacheYesNever hardcoded
Post-login redirect validated against allowlistYesPrevent open redirect within app
Application-level CSRF protection in placeYesSameSite cookies and/or CSRF tokens

Further reading

Updated 2026-07-03