Skip to main content
Version 0.3Draft

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_uri is allow-listed with LuxID. See Configure LuxID.
  • You have decided which scopes you need (openid is 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:

ParameterValueNotes
response_typecodeAlways code for this flow
client_idyour-client-idIssued by POST Luxembourg
redirect_urihttps://yourapp.example/callbackMust match exactly what is registered
scopeopenid profile emailSpace-separated; openid is mandatory
stateRandom valueBinds the response to your request session
nonceRandom valueBinds the ID token to this request
code_challengeDerived aboveSHA-256 hash of the verifier, base64url
code_challenge_methodS256Always 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
Validate state before doing anything else

If the returned state does not match what you stored in step 1, discard the request - it is a potential CSRF attack.

# Flask
if request.args['state'] != session.pop('oauth_state'):
raise SecurityError("State mismatch - possible CSRF attack")

code = request.args['code']

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.

Never exchange the code in the browser

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:

TokenLifetime
Access tokenShort-lived
ID token~1 hour
Refresh tokenLong-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.

  1. Signature - Verify the RS256 signature using the public keys from the JWKS endpoint: https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID Match the key by kid. Cache the JWKS and refresh only on unknown kid (see Key management).

  2. Issuer (iss) - Must be exactly https://login.luxid.lu. String comparison, not URL parsing.

  3. Audience (aud) - Must contain your client_id. If aud is an array, your client_id must appear in it.

  4. Expiry (exp) - Must be in the future. A clock skew allowance of up to 60 seconds is acceptable.

  5. Nonce - Must match the nonce value 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.

# 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']

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

PlatformUse thisNever use
iOSASWebAuthenticationSession (opens in a new tab)WKWebView / UIWebView for authentication
AndroidCustom 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:

iOS system dialog reading: YourApp Wants to Use login.luxid.lu to Sign In. This allows the app and website to share information about you. Buttons: Cancel and Continue.

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.

Leave prefersEphemeralWebBrowserSession at its default

Setting 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

MistakeConsequenceFix
Not validating stateCSRF vulnerabilityAlways compare state before using the code
Not validating nonce in ID tokenToken replay possibleAlways check nonce matches
Skipping signature verificationToken forgery possibleUse a JWT library with JWKS fetching
Storing tokens in localStorageXSS can steal tokensUse HTTP-only server-side sessions
Hardcoding the JWKSKey rotation breaks your appCache and refresh on unknown kid
Token exchange in the browserCode and secret exposedAlways POST to token endpoint from your server
Mismatched redirect_uriredirect_uri_mismatch errorExact string match required - including trailing slash

Next steps

Updated 2026-07-03