Add login to your Application
What you will build
By the end of this guide your application will redirect users to LuxID's hosted login page, receive an authorisation code, exchange it for tokens, validate the ID token, and establish a local session. The entire flow uses Authorization Code + PKCE (RFC 7636), which is the correct grant for all application types - web apps, SPAs, and native apps alike.
What you need before you begin
- Your application is registered with POST Luxembourg and you have a
client_id. - Your
redirect_uriis allow-listed with LuxID. See Configure LuxID. - You have decided which scopes you need (
openidis always required). - For confidential clients (server-side apps): you also have a
client_secret.
Flow overview
Step 1 - generate PKCE parameters
PKCE prevents authorisation code interception attacks. Generate a cryptographically random code_verifier, then derive the code_challenge from it.
# Generate a 32-byte (256-bit) code_verifier, base64url-encoded (no padding)
CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
# Derive code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "code_verifier: $CODE_VERIFIER"
echo "code_challenge: $CODE_CHALLENGE"
The code_verifier must be between 43 and 128 characters of unreserved ASCII characters ([A-Z] [a-z] [0-9] - . _ ~). The generated output above satisfies this constraint.
Also generate a state (CSRF token) and a nonce:
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
Store CODE_VERIFIER, STATE, and NONCE server-side (or in sessionStorage for SPAs) before redirecting. You will need them in steps 2 and 4.
Step 2 - build the authorisation URL
Redirect the user's browser to the LuxID authorisation endpoint with the following query parameters:
| Parameter | Value | Notes |
|---|---|---|
response_type | code | Always code for this flow |
client_id | your-client-id | Issued by POST Luxembourg |
redirect_uri | https://yourapp.example/callback | Must match exactly what is registered |
scope | openid profile email | Space-separated; openid is mandatory |
state | Random value | Binds the response to your request session |
nonce | Random value | Binds the ID token to this request |
code_challenge | Derived above | SHA-256 hash of the verifier, base64url |
code_challenge_method | S256 | Always S256 - plain is not supported |
Example URL (line-wrapped for readability):
https://login.luxid.lu/mga/sps/oauth/oauth20/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
&scope=openid%20profile%20email
&state=STATE_VALUE
&nonce=NONCE_VALUE
&code_challenge=CODE_CHALLENGE_VALUE
&code_challenge_method=S256
The user is sent to LuxID's Universal Login page. LuxID handles all credential collection, MFA prompts, and session checks. Your application does not need to render any login UI.
Optional parameters
Add lang=fr (or de, en, or lu for Luxembourgish) to request a specific login-page language. This is a LuxID-specific query parameter, not the OIDC ui_locales parameter; note the value for Luxembourgish is lu, even though the BCP47 language code is lb. Add acr_values with the value agreed with LuxID during integration to require a minimum auth_level (see Authentication levels for the acr_values mechanism and common forms).
Step 3 - handle the callback
After the user authenticates, LuxID redirects the browser to your redirect_uri with:
https://yourapp.example/callback?code=AUTHORISATION_CODE&state=STATE_VALUE
state before doing anything elseIf the returned state does not match what you stored in step 1, discard the request - it is a potential CSRF attack.
- Python
- Java
- PHP
- Node.js
- C#
# Flask
if request.args['state'] != session.pop('oauth_state'):
raise SecurityError("State mismatch - possible CSRF attack")
code = request.args['code']
// Spring MVC
if (!request.getParameter("state").equals(session.getAttribute("oauth_state"))) {
throw new SecurityException("State mismatch - possible CSRF attack");
}
session.removeAttribute("oauth_state");
String code = request.getParameter("code");
// Constant-time comparison to avoid timing leaks
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
throw new RuntimeException('State mismatch - possible CSRF attack');
}
unset($_SESSION['oauth_state']);
$code = $_GET['code'];
// Express + express-session
if (req.query.state !== req.session.oauthState) {
throw new Error('State mismatch - possible CSRF attack');
}
delete req.session.oauthState;
const code = req.query.code;
// ASP.NET Core
if (Request.Query["state"] != HttpContext.Session.GetString("oauth_state"))
throw new SecurityException("State mismatch - possible CSRF attack");
HttpContext.Session.Remove("oauth_state");
var code = Request.Query["code"].ToString();
On error, LuxID redirects with error and error_description query parameters instead. See OAuth and OIDC error codes for the full list.
https://yourapp.example/callback?error=access_denied&error_description=The+user+denied+access
Step 4 - exchange the code for tokens
Make a server-side POST request to the token endpoint.
The code_verifier and client_secret (for confidential clients) must be kept server-side.
Public client (SPA / native app - no client_secret)
curl -X POST https://login.luxid.lu/mga/sps/oauth/oauth20/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORISATION_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code_verifier=CODE_VERIFIER_VALUE"
Confidential client (server-side web app - with client_secret)
Using client_secret_post (credentials in the request body):
curl -X POST https://login.luxid.lu/mga/sps/oauth/oauth20/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORISATION_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code_verifier=CODE_VERIFIER_VALUE"
Alternatively, use client_secret_basic (credentials in the Authorization header):
curl -X POST https://login.luxid.lu/mga/sps/oauth/oauth20/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Basic $(echo -n 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' | base64)" \
-d "grant_type=authorization_code" \
-d "code=AUTHORISATION_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "code_verifier=CODE_VERIFIER_VALUE"
Token response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 600,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "a2b3c4d5e6f7...",
"scope": "openid profile email"
}
Token lifetimes:
| Token | Lifetime |
|---|---|
| Access token | Short-lived |
| ID token | ~1 hour |
| Refresh token | Long-lived |
Step 5 - validate the ID Token
The ID token is a signed JWT. You must validate it before trusting its claims. Never skip this step.
Decoded example
Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "OIDC-LUXID-2024-01"
}
Payload:
{
"iss": "https://login.luxid.lu",
"sub": "7f3a2b1e-4c8d-4e9f-a1b2-c3d4e5f67890",
"aud": "your-client-id",
"exp": 1748912345,
"iat": 1748908745,
"auth_time": 1748908740,
"nonce": "your-nonce-value",
"acr": "urn:luxid:acr:level:substantial",
"amr": ["pwd", "otp"],
"name": "Marie Dupont",
"given_name": "Marie",
"family_name": "Dupont",
"email": "marie.dupont@example.lu",
"email_verified": true,
"phone_number": "+352621123456",
"phone_number_verified": true,
"updated_at": 1748800000
}
Mandatory validation checks (OIDC core 1.0 §3.1.3.7)
Perform all five checks. Reject the token if any fails.
-
Signature - Verify the RS256 signature using the public keys from the JWKS endpoint:
https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXIDMatch the key bykid. Cache the JWKS and refresh only on unknownkid(see Key management). -
Issuer (
iss) - Must be exactlyhttps://login.luxid.lu. String comparison, not URL parsing. -
Audience (
aud) - Must contain yourclient_id. Ifaudis an array, yourclient_idmust appear in it. -
Expiry (
exp) - Must be in the future. A clock skew allowance of up to 60 seconds is acceptable. -
Nonce - Must match the
noncevalue you sent in step 2. This binds the token to your specific authorisation request and prevents replay attacks.
Validation with cURL + manual decode
# Fetch the JWKS
curl -s https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID | python3 -m json.tool
# Decode the ID token payload (without verifying signature - for debugging only)
ID_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
echo "$ID_TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool
In production, use a proper JWT library - do not decode without verifying. See Code samples and reference repos for JWT library recommendations per language.
Step 6 - establish a local session
Once the ID token is validated, create a session in your own application. Store what you need - typically the sub (the stable user identifier), name, email, and the access_token if you need to call the UserInfo endpoint or a protected API.
Do not store the raw id_token or access_token in a browser cookie without encryption. Use an HTTP-only, Secure, SameSite=Strict cookie containing a server-side session identifier instead.
See Session management for cookie configuration, session invalidation, and logout patterns.
- Python
- Java
- PHP
- Node.js
- C#
# Flask
session['user_id'] = id_token_claims['sub']
session['user_name'] = id_token_claims['name']
session['user_email'] = id_token_claims['email']
# Store the access_token server-side if you need to call APIs on the user's behalf
session['access_token'] = token_response['access_token']
session['refresh_token'] = token_response['refresh_token']
// Spring MVC HttpSession
session.setAttribute("userId", idTokenClaims.getStringClaim("sub"));
session.setAttribute("userName", idTokenClaims.getStringClaim("name"));
session.setAttribute("userEmail", idTokenClaims.getStringClaim("email"));
// Store the access token server-side if you need to call APIs on the user's behalf
session.setAttribute("accessToken", tokenResponse.getAccessToken());
session.setAttribute("refreshToken", tokenResponse.getRefreshToken());
$_SESSION['user_id'] = $idTokenClaims['sub'];
$_SESSION['user_name'] = $idTokenClaims['name'];
$_SESSION['user_email'] = $idTokenClaims['email'];
// Store the access token server-side if you need to call APIs on the user's behalf
$_SESSION['access_token'] = $tokenResponse['access_token'];
$_SESSION['refresh_token'] = $tokenResponse['refresh_token'];
// Express + express-session
req.session.userId = idTokenClaims.sub;
req.session.userName = idTokenClaims.name;
req.session.userEmail = idTokenClaims.email;
// Store the access token server-side if you need to call APIs on the user's behalf
req.session.accessToken = tokenResponse.access_token;
req.session.refreshToken = tokenResponse.refresh_token;
// ASP.NET Core session
HttpContext.Session.SetString("userId", idTokenClaims["sub"]);
HttpContext.Session.SetString("userName", idTokenClaims["name"]);
HttpContext.Session.SetString("userEmail", idTokenClaims["email"]);
// Store the access token server-side if you need to call APIs on the user's behalf
HttpContext.Session.SetString("accessToken", tokenResponse.AccessToken);
HttpContext.Session.SetString("refreshToken", tokenResponse.RefreshToken);
The sub claim is the stable, Sphere-scoped pseudonymous identifier for the user. It does not change when the user updates their email address. Treat it as a stable foreign key for linking LuxID identities to records in your own database - but be aware that it is not eternal: if a user revokes their last Subscription in your Sphere and later returns, LuxID may issue a new sub. See The Subscription for the full lifecycle.
Refresh tokens
Access tokens are short-lived (read expires_in from the token response for the value that applies). Use the refresh token to obtain a new access token without re-authenticating the user.
curl -X POST https://login.luxid.lu/mga/sps/oauth/oauth20/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
The response contains a new access_token and typically a new refresh_token (rotating refresh tokens). Replace both in your session store.
refresh_token is only issued when offline_access is included in the scope. See OpenID Connect for full refresh token details.
Native and mobile apps
Native iOS and Android apps run the exact same Authorization Code + PKCE flow described above. The one thing that changes is how you open the authorisation URL: you must hand it to the operating system's secure in-app browser, never to an embedded web view. This is the requirement of RFC 8252 - OAuth 2.0 for Native Apps (opens in a new tab) (IETF, October 2017).
Use the system browser, not a WebView
| Platform | Use this | Never use |
|---|---|---|
| iOS | ASWebAuthenticationSession (opens in a new tab) | WKWebView / UIWebView for authentication |
| Android | Custom Tabs (opens in a new tab) (androidx.browser, as used by AppAuth) | WebView |
An embedded WebView is controlled by your app, which defeats the whole point of redirecting to LuxID. With a WebView your app could read the user's keystrokes, no saved login.luxid.lu session is shared (so single sign-on is broken), and password managers and passkeys do not work. Using a WebView for LuxID authentication is a violation of the LuxID Agreement.
ASWebAuthenticationSession (iOS) and Custom Tabs (Android) run the page in the platform's own browser process. The user gets the saved LuxID session (SSO across apps), password-manager and one-time-code autofill, passkeys, and the platform's certificate validation - none of which a WebView can offer.
Redirect URI on native apps
Register an app-specific redirect URI with LuxID. Two patterns are accepted:
- Custom scheme - for example
lu.example.myapp:/oauth2redirect. Simple and widely supported. - HTTPS app link / universal link - for example
https://myapp.example.lu/oauth2redirect, claimed by your app through iOS Universal Links or Android App Links. More resistant to scheme hijacking; prefer this where you control a domain.
The redirect URI is allow-listed by exact match, like any other. See Redirect URIs and domains.
The iOS "Wants to Use ... to Sign In" dialog
The first time an iOS app starts an ASWebAuthenticationSession against login.luxid.lu, the system shows a consent dialog:
This dialog is shown by iOS itself - not by LuxID and not by your app. It appears because ASWebAuthenticationSession is about to share the login.luxid.lu browser cookies with the in-app browser, which is exactly what makes single sign-on possible. iOS asks the user to approve that sharing once. Tapping Continue proceeds to LuxID (reusing an existing LuxID session if there is one); tapping Cancel aborts the flow with a user-cancelled error, which your app should handle gracefully.
If your users ask why they see this prompt, the answer is: it is a standard iOS privacy prompt confirming that the app may use the LuxID website to sign in. It is not a LuxID error and nothing is wrong.
prefersEphemeralWebBrowserSession at its defaultSetting prefersEphemeralWebBrowserSession (opens in a new tab) to true runs the session in a private, cookie-isolated browser. That suppresses the iOS consent dialog - but it also means no LuxID session is shared or stored, so single sign-on is lost and the user authenticates from scratch every time. Keep the default (false) so users benefit from SSO across LuxID-enabled apps. Reserve the ephemeral mode for explicit "sign in as a different user" or shared-device scenarios.
Android's Custom Tabs share cookies with the user's browser profile by the same mechanism, giving the same SSO. Android does not show an equivalent per-app system consent dialog - the cookie sharing is implicit in the Custom Tab, so there is no Android pendant to explain to users.
Where the dialog fits in the flow
For ready-made native integrations that wrap these APIs correctly (AppAuth-iOS, AppAuth-Android, react-native-app-auth), see Platform and framework guides.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Not validating state | CSRF vulnerability | Always compare state before using the code |
Not validating nonce in ID token | Token replay possible | Always check nonce matches |
| Skipping signature verification | Token forgery possible | Use a JWT library with JWKS fetching |
| Storing tokens in localStorage | XSS can steal tokens | Use HTTP-only server-side sessions |
| Hardcoding the JWKS | Key rotation breaks your app | Cache and refresh on unknown kid |
| Token exchange in the browser | Code and secret exposed | Always POST to token endpoint from your server |
Mismatched redirect_uri | redirect_uri_mismatch error | Exact string match required - including trailing slash |
Next steps
- Read OpenID Connect for the full parameter reference, silent re-auth, logout, and advanced flows.
- Read Protect your Application for HTTPS requirements, CSRF mitigations, and security headers.
- Read Session management for logout, session expiry, and cookie configuration.
- Pick a library for your stack in Platform and framework guides - most of the above is handled automatically.