Configure LuxID
Overview
This page walks through the complete path from zero to a working test login. There are five stages:
- Become a LuxID Partner (business agreement)
- Register your Application (identity, redirect URIs, Claim Templates, access rules)
- Communicate your UAT passlist entries (email domains, phone numbers)
- Configure your environment (UAT then production)
- Run the first test login (Authorization Code + PKCE)
Stage 1 - become a LuxID Partner
LuxID is a closed ecosystem. You must sign a LuxID Agreement before receiving credentials.
Contacts:
- Business enquiries (joining LuxID, commercial discussions): LuxID
- Operational support (onboarding follow-up, technical questions, incidents): LuxID
During onboarding, POST Luxembourg will:
- Confirm your Organisation's eligibility
- Provide the LuxID Agreement for signature
- Create your Organisation record and assign a Partner
- Provision your UAT credentials
The LuxID Agreement defines the permitted use cases, data handling obligations, and acceptable claim scope. Read it carefully - it constrains what claims you may request.
The Agreement also includes an Ethical Code of Conduct that LuxID Partners commit to respecting. The Code sets the behavioural standards expected of every Partner regarding user privacy, fair use of the LuxID surface, and overall ecosystem stewardship.
There is no self-serve sign-up page. By design, LuxID keeps Partner onboarding human-driven and values proximity to its Partners - the first step is a conversation, not a form. Reach out via the contacts above and LuxID will guide you from there.
How the conversation unfolds
After initial contact, LuxID follows up to understand your needs. A short exchange between technical teams typically clarifies which standard (OIDC, OAuth 2.0, or SAML) fits best, what claims you'll need, and what assurance level your service requires. From that exchange, LuxID creates your Application in the UAT environment based on the registration information you provide.
Stage 2 - register your Application
You provide the Application registration information to LuxID; LuxID performs the registration on your behalf and returns your credentials. The dedicated Register an Application page is the canonical list of every field, with formats, constraints, and examples - use it as the template for your submission.
At a high level, expect to provide:
- Application identity - a display name and logo (square
.png/.jpeg, min 64x64 px, works in light and dark mode), shown on the LuxID consent screen. - Protocol - OIDC, OAuth 2.0, or SAML.
- Public URL - the landing page of your service.
- Redirect URI(s) - one or several callback URLs. LuxID enforces exact-match on these, so register every variant you actually use (e.g.
https://app.example.lu/auth/callback,myapp://auth/callback,http://localhost:3000/auth/callbackfor UAT). - Contacts - an administrative contact and a technical contact.
- Claim Templates - the claims your Application will request (e.g.
given_name,email,phone_number), each marked essential or voluntary, with optional per-claim hint labels. LuxID reviews the requested set against data minimisation; you can only receive claims that were declared at registration. See the available claims list below. - Access rules - any Auth requirements (minimum
auth_level), Guard rules, or User rules you want LuxID to evaluate on sign-in. Can also be added later by request.
A few notes specific to this stage:
- Sphere assignment is not a Partner choice. LuxID assigns your Application to a Sphere as an outcome of the technical exchange in Stage 1, based on which other Applications you should (or should not) share a pseudonymous
subwith. If you want consistentsubvalues across several of your Applications - or across Applications from multiple Organisations - tell LuxID early so the Sphere is set up correctly. See Plan and design for the design rationale. - Redirect URIs are exact-match. HTTPS is required in Production;
http://localhostis allowed in UAT only; no wildcards. An Application can have several redirect URIs registered at once, and LuxID accepts any request whoseredirect_urimatches one of them. This is what makes a zero-downtime migration possible: register the new callback alongside the old one, cut traffic over at your own pace, then ask LuxID to remove the old URI once the new one is validated. See Redirect URIs and domains for the full rules.
What you will receive
Once your Application has been registered by LuxID, you will receive:
- Client ID - a stable public identifier for your Application
- Client Secret - a confidential shared secret (treat as a password; rotate periodically)
LuxID Partner authentication is based on Client ID and Client Secret. A self-service LuxID Console is on the roadmap; in the interim, Application configuration changes happen by request to LuxID (see LuxID Console for the future surface and the current request-based procedure).
Available claims
LuxID supports the following OIDC standard claims. You may only receive claims that were declared at registration and that the user consented to share.
| Claim | Scope required | Description |
|---|---|---|
sub | openid | Pseudonymous user identifier (Sphere-scoped) |
name | profile | Full name |
given_name | profile | Given name |
family_name | profile | Family name |
birthdate | profile | Date of birth (YYYY-MM-DD) |
gender | profile | Gender, self-declared in the user's profile (not verified) |
updated_at | profile | Last profile update timestamp |
email | email | Email address |
email_verified | email | Whether email has been verified |
phone_number | phone | Phone number |
phone_number_verified | phone | Whether phone number has been verified |
address | address | Postal address (object); available via Claim Template, not advertised in discovery |
age_over_16 | profile | Boolean age gate. Defined in the OIDC schema but not currently offered by LuxID |
Apply the principle of claim minimisation - request only what your application genuinely needs. Requesting unnecessary claims increases consent friction and creates data liability.
Beyond the standard claims above, your partnership can have bespoke technical claims provisioned by LuxID - partner-supplied data such as a membership or registration number that LuxID carries back to you on every sign-in. They follow the luxid_<partner-prefix>_<name> naming convention, are declared in your Claim Template, and their value is set per user by your back-end via the Partner API claims capability. A declared-but-never-set technical claim does not appear in the token. See Tokens and claims - partner-specific technical claims.
Stage 3 - communicate your UAT passlist entries
The UAT environment uses a passlist (whitelist) on the user-facing communication channels to keep test traffic isolated from real users. Before you can complete an end-to-end sign-in flow in UAT, you need to register at least:
- The email domain(s) the test user accounts will be on - LuxID's UAT only sends activation links, password resets, and other transactional emails to addresses whose domain is on the passlist.
- The phone number(s) the test users will use, if your Application requires SMS-based OTP - LuxID's UAT only sends SMS and voice calls to numbers on the passlist.
Provide these to LuxID during onboarding (or by email to LuxID).
Domain rule. The email domain you register must be a domain you own and control (typically your own corporate domain, or a dedicated test sub-domain such as qa.example.lu). Free email providers - Gmail, Outlook.com, Yahoo, ProtonMail, iCloud, and similar - are not accepted on the UAT passlist. This is an intentional safety guardrail to prevent test signups from spilling into real consumer mailboxes and to keep UAT traffic clearly distinguishable from production.
Phone rule. Phone numbers are registered as full E.164 strings (e.g. +352621234567). Wildcards are not supported. Register only numbers you control - LuxID will send real OTP messages to them.
Symptoms of missing passlist entries: activation emails never arrive, OTP SMS never arrives, password reset links are not delivered. If you see any of these during UAT testing, check first whether the recipient domain or number is on the passlist.
Production has no passlist - LuxID sends to any valid recipient. The passlist exists only in UAT.
Stage 4 - choose your environment
LuxID provides two environments. Use UAT for all development and testing. Never test against production.
All endpoint paths are identical between environments. Replace the host to switch. SAML metadata:
| Environment | SAML IdP metadata |
|---|---|
| UAT | Download UAT SAML metadata |
| Production | Download production SAML metadata |
OIDC endpoints (production)
Fetch the discovery document to confirm endpoints and supported capabilities programmatically. Most OIDC libraries do this automatically.
Stage 5 - first test login
The recommended flow for all new integrations is Authorization Code + PKCE (RFC 7636 (opens in a new tab)). It is secure for both public clients (SPAs, mobile apps) and confidential clients (server-side apps).
Flow overview
Step-by-step
Step 1 - Generate PKCE values
Generate a cryptographically random code_verifier (43-128 characters, URL-safe). Derive code_challenge as the Base64-URL-encoded SHA-256 hash of the verifier.
- Bash (Linux / macOS)
- PowerShell (Windows)
code_verifier=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '=')
echo "code_verifier=$code_verifier"
echo "code_challenge=$code_challenge"
function New-Base64UrlBytes([int]$length) {
$bytes = New-Object byte[] $length
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_')
}
$codeVerifier = New-Base64UrlBytes 48
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$hash = $sha256.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($codeVerifier))
$codeChallenge = [Convert]::ToBase64String($hash).TrimEnd('=').Replace('+','-').Replace('/','_')
"code_verifier=$codeVerifier"
"code_challenge=$codeChallenge"
Step 2 - Generate state and nonce
- Bash (Linux / macOS)
- PowerShell (Windows)
state=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-32)
nonce=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-32)
$state = New-Base64UrlBytes 24 # CSRF protection
$nonce = New-Base64UrlBytes 24 # Replay protection for ID token
Store state, nonce, and code_verifier server-side (or in a short-lived session cookie) before redirecting.
Step 3 - Build the authorisation URL
Assemble the URL from the values produced in Steps 1-2 (code_challenge, state, nonce) plus your Client ID and redirect URI. The script prints the URL - redirect the user's browser to it to start the login.
- Bash (Linux / macOS)
- PowerShell (Windows)
# Reuses code_challenge, state, nonce from the previous steps
authorize_endpoint="https://login-uat.luxid.lu/mga/sps/oauth/oauth20/authorize"
client_id="$LUXID_CLIENT_ID"
redirect_uri="https://app.example.lu/auth/callback"
scope="openid profile email"
# Minimal RFC 3986 encoder (state/nonce/code_challenge are already URL-safe)
urlencode() {
local s="$1" o="" i c
for (( i=0; i<${#s}; i++ )); do
c="${s:i:1}"
case "$c" in
[a-zA-Z0-9.~_-]) o+="$c" ;;
*) printf -v c '%%%02X' "'$c"; o+="$c" ;;
esac
done
printf '%s' "$o"
}
authorization_url="${authorize_endpoint}?response_type=code"
authorization_url+="&client_id=$(urlencode "$client_id")"
authorization_url+="&redirect_uri=$(urlencode "$redirect_uri")"
authorization_url+="&scope=$(urlencode "$scope")"
authorization_url+="&state=${state}"
authorization_url+="&nonce=${nonce}"
authorization_url+="&code_challenge=${code_challenge}"
authorization_url+="&code_challenge_method=S256"
echo "$authorization_url"
# Reuses $codeChallenge, $state, $nonce from the previous steps
$authorizeEndpoint = "https://login-uat.luxid.lu/mga/sps/oauth/oauth20/authorize"
$clientId = $env:LUXID_CLIENT_ID
$redirectUri = "https://app.example.lu/auth/callback"
$scope = "openid profile email"
$params = [ordered]@{
response_type = 'code'
client_id = $clientId
redirect_uri = $redirectUri
scope = $scope
state = $state
nonce = $nonce
code_challenge = $codeChallenge
code_challenge_method = 'S256'
}
$query = ($params.GetEnumerator() | ForEach-Object {
"{0}={1}" -f $_.Key, [uri]::EscapeDataString([string]$_.Value)
}) -join '&'
$authorizationUrl = '{0}?{1}' -f $authorizeEndpoint, $query
$authorizationUrl
Step 4 - Handle the callback
LuxID redirects to your redirect_uri with the authorisation code and your state:
https://app.example.lu/auth/callback?code=AUTH_CODE&state=STATE
Parse that callback URL, then verify state matches the value you stored in Step 2 before using the code - this is the CSRF protection check. The script extracts the code into the variable Step 5 expects.
- Bash (Linux / macOS)
- PowerShell (Windows)
# The full callback URL your redirect_uri received (from your web framework's request)
callback_url="https://app.example.lu/auth/callback?code=AUTH_CODE&state=STATE"
query="${callback_url#*\?}"
get_param() { printf '%s' "$query" | tr '&' '\n' | grep "^$1=" | cut -d= -f2- ; }
returned_state=$(get_param state)
# CSRF check against the state stored in Step 2
if [ "$returned_state" = "$state" ]; then
AUTH_CODE=$(get_param code)
echo "AUTH_CODE=$AUTH_CODE"
else
echo "State mismatch - abort (possible CSRF)" >&2
fi
# The full callback URL your redirect_uri received (from your web framework's request)
$callbackUrl = "https://app.example.lu/auth/callback?code=AUTH_CODE&state=STATE"
$query = [System.Uri]::new($callbackUrl).Query.TrimStart('?')
$parsed = @{}
foreach ($pair in $query -split '&') {
$kv = $pair -split '=', 2
$parsed[$kv[0]] = [uri]::UnescapeDataString($kv[1])
}
# CSRF check against the $state stored in Step 2
if ($parsed['state'] -eq $state) {
$authCode = $parsed['code']
"authCode=$authCode"
} else {
Write-Error "State mismatch - abort (possible CSRF)"
}
Step 5 - Exchange the code for tokens
POST the authorisation code, along with the code_verifier from Step 1 and your Client ID + Client Secret, to the token endpoint.
- Bash (Linux / macOS)
- PowerShell (Windows)
curl -sS -X POST "https://login-uat.luxid.lu/mga/sps/oauth/oauth20/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$AUTH_CODE" \
--data-urlencode "redirect_uri=https://app.example.lu/auth/callback" \
--data-urlencode "client_id=$LUXID_CLIENT_ID" \
--data-urlencode "client_secret=$LUXID_CLIENT_SECRET" \
--data-urlencode "code_verifier=$code_verifier"
$body = @{
grant_type = 'authorization_code'
code = $authCode
redirect_uri = 'https://app.example.lu/auth/callback'
client_id = $env:LUXID_CLIENT_ID
client_secret = $env:LUXID_CLIENT_SECRET
code_verifier = $codeVerifier
}
$tokens = Invoke-RestMethod `
-Method POST `
-Uri "https://login-uat.luxid.lu/mga/sps/oauth/oauth20/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body $body
$tokens | ConvertTo-Json
The response is a JSON object:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 600,
"id_token": "...",
"refresh_token": "...",
"scope": "openid profile email"
}
Step 6 - Validate the ID token
Before trusting any claims, validate the ID token:
- Fetch the JWKS from the environment-appropriate URL and cache it:
- Verify the JWT signature using the matching key (
kidheader claim). - Verify
issequalshttps://login-uat.luxid.lu(UAT) orhttps://login.luxid.lu(production). - Verify
audcontains yourclient_id. - Verify
expis in the future. - Verify
noncematches the value you stored in Step 2.
Use a well-maintained OIDC library for your language rather than implementing JWT validation by hand. Most libraries perform all six checks automatically when configured with the issuer and client ID.
Step 7 - Establish your application session
Once the ID token is validated, create your own application session (e.g. a signed cookie or server-side session record) keyed on the sub claim. Store the refresh token securely for silent renewal.
Do not pass the access token to your frontend unless your frontend needs to call a resource server. The ID token is for your server only - do not expose it to the browser.
Token lifetimes
| Token | Lifetime |
|---|---|
| Access token | Short-lived |
| ID token | ~1 hour |
| Refresh token | Long-lived |
You can read the exact access token lifetime from the expires_in field in the token response and from the exp claim in the access and ID tokens. The refresh token lifetime is not returned in the response - ask LuxID if you need the exact value. Do not hardcode lifetimes; drive renewal from the exp claim.
LuxID rotates refresh tokens on use, with immediate revocation of the previous pair. When you exchange a refresh token at the token endpoint, LuxID issues a new access token AND a new refresh token, and the previous access token and the previous refresh token are revoked immediately - not lazily on first use of the new pair (as some other IdPs do).
Two practical consequences:
- Always discard the old refresh token and persist the new one as soon as you receive the token response. A successful exchange is the point of no return.
- Serialise concurrent refresh attempts. If two threads, processes, or app instances try to refresh from the same refresh token in parallel, one will succeed and invalidate the token while the other is mid-flight. The losing call gets
invalid_grantand the user is forced back to interactive sign-in. Guard the refresh path with a per-user mutex or a single-flight pattern.
Troubleshooting your first login
If the authorisation redirect fails, check:
- The
redirect_uriexactly matches a registered URI (scheme, host, port, path, no trailing slash difference) - The
client_idis for the correct environment (UAT vs production) - The
scopevalues are space-separated and URL-encoded
If the token exchange fails with invalid_grant:
- The authorisation code has already been used (codes are single-use)
- The
code_verifierdoes not match thecode_challengefrom Step 3 - The code has expired (codes are short-lived - exchange it promptly)
For a full error reference see Common errors.
Next steps
- Plan and design - architectural decisions before production
- Add Login to your app - framework-specific quickstarts
- OpenID Connect - full OIDC integration reference
- Register an Application - full Console reference