Platform and framework guides
Overview
LuxID does not publish a first-party SDK. Integration is done with community-maintained OpenID Connect libraries. Every library listed in this guide is mature, actively maintained, has undergone security review by its respective community, and is widely deployed in production. Do not write your own token validation or PKCE implementation - use the libraries.
All examples on this page share the same LuxID endpoints:
| Endpoint | URL |
|---|---|
| Discovery | https://login.luxid.lu/.well-known/openid-configuration |
| Issuer | https://login.luxid.lu |
| Authorisation | https://login.luxid.lu/mga/sps/oauth/oauth20/authorize |
| Token | https://login.luxid.lu/mga/sps/oauth/oauth20/token |
| JWKS | https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID |
UAT mirrors replace login.luxid.lu with login-uat.luxid.lu. All other path segments remain identical.
Obtain your client_id and client_secret from LuxID after completing Configure LuxID. Allowed redirect URIs are registered at the same time.
Spring security (OAuth2 client / OIDC login)
Library: Spring Security OAuth2 Client, built into Spring Security 6+.
Dependency (pom.xml):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Configuration (application.yml):
spring:
security:
oauth2:
client:
provider:
luxid:
issuer-uri: https://login.luxid.lu
registration:
luxid:
provider: luxid
client-id: ${LUXID_CLIENT_ID}
client-secret: ${LUXID_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope:
- openid
- profile
- email
redirect-uri: "{baseUrl}/login/oauth2/code/luxid"
client-name: LuxID
Spring Security resolves all provider metadata (authorisation endpoint, token endpoint, JWKS URI) automatically from the issuer-uri discovery document. No manual endpoint configuration is required.
Security configuration class:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
}
Things to watch out for:
- PKCE (
S256) is enforced automatically in Spring Security 6+. No extra configuration is needed. - The default redirect URI template uses
{baseUrl}- ensure your registered redirect URI with LuxID matches the resolved value exactly, including scheme and port. - In production behind a reverse proxy, set
server.forward-headers-strategy: frameworkso{baseUrl}resolves tohttps://rather thanhttp://. - Claims from the UserInfo endpoint are not automatically merged into the principal. Configure an
OidcUserServicebean if you needprofileoremailclaims on theOidcUser. - Refresh tokens are issued when the
offline_accessscope is included. Spring Security handles silent refresh automatically whenoffline_accessis in the registration scopes.
ASP.NET core (OpenIdConnect middleware)
Library: Microsoft.AspNetCore.Authentication.OpenIdConnect, included in the ASP.NET Core shared framework from .NET 6 onwards. No additional NuGet package is needed for most scenarios, though some projects pin the version explicitly.
Optional explicit pin (*.csproj):
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.*" />
Configuration (Program.cs):
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://login.luxid.lu";
options.ClientId = builder.Configuration["LuxID:ClientId"];
options.ClientSecret = builder.Configuration["LuxID:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("profile");
options.Scope.Add("email");
// Uncomment to request refresh tokens:
// options.Scope.Add("offline_access");
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (HttpContext ctx) =>
ctx.User.Identity!.Name ?? "anonymous").RequireAuthorization();
app.Run();
Things to watch out for:
- Set
GetClaimsFromUserInfoEndpoint = true- without it, theemailandprofileclaims are not populated from the UserInfo response, only from the (slim) ID token. SaveTokens = truepersists the access token and refresh token in the authentication cookie so you can retrieve them later viaHttpContext.GetTokenAsync("access_token").- PKCE is enabled automatically from ASP.NET Core 7 onwards. Do not disable it.
options.Authoritytriggers metadata discovery on first request. The metadata is cached by default; no extra action is needed.- Store
ClientSecretin environment variables or user secrets - never inappsettings.jsonchecked into source control. - For multi-environment deployments, externalise
Authorityand the redirect URI to environment-specific configuration rather than hardcoding.
Node.js (openid-client)
Library: openid-client (opens in a new tab) by Filip Skokan. Implements OIDC Core, PKCE, and the full token lifecycle.
Install:
npm install openid-client
Issuer discovery and client setup:
import { Issuer, generators } from 'openid-client';
const luxidIssuer = await Issuer.discover('https://login.luxid.lu');
const client = new luxidIssuer.Client({
client_id: process.env.LUXID_CLIENT_ID,
client_secret: process.env.LUXID_CLIENT_SECRET,
redirect_uris: ['https://yourapp.example.com/auth/callback'],
response_types: ['code'],
});
Express route handlers:
import express from 'express';
import session from 'express-session';
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
// Start login
app.get('/auth/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
req.session.codeVerifier = codeVerifier;
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
// Handle callback
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'https://yourapp.example.com/auth/callback',
params,
{ code_verifier: req.session.codeVerifier }
);
req.session.claims = tokenSet.claims();
req.session.accessToken = tokenSet.access_token;
res.redirect('/dashboard');
});
Things to watch out for:
- Always generate a fresh
codeVerifierper login attempt and store it in the server-side session, not in a cookie. openid-clientvalidates the ID token signature, issuer, audience, nonce, andiat/expautomatically. Do not bypass this validation.- JWKS keys are cached and rotated transparently. No manual key refresh is needed.
- If you need to call the UserInfo endpoint explicitly, use
client.userinfo(accessToken). - Dispose of
codeVerifierfrom the session after the callback is processed to prevent replay.
Next.js (auth.js / NextAuth.js)
Library: Auth.js (opens in a new tab) (formerly NextAuth.js). Version 5 is recommended for the Next.js App Router.
Install:
npm install next-auth
Route handler (app/api/auth/[...nextauth]/route.ts):
import NextAuth from 'next-auth';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: 'luxid',
name: 'LuxID',
type: 'oidc',
issuer: 'https://login.luxid.lu',
clientId: process.env.LUXID_CLIENT_ID!,
clientSecret: process.env.LUXID_CLIENT_SECRET!,
authorization: {
params: {
scope: 'openid profile email',
},
},
},
],
callbacks: {
async session({ session, token }) {
// Expose the sub claim on the session if needed:
session.user.id = token.sub ?? '';
return session;
},
},
});
export const { GET, POST } = handlers;
Environment variables (.env.local):
LUXID_CLIENT_ID=your-client-id
LUXID_CLIENT_SECRET=your-client-secret
AUTH_SECRET=a-random-32-char-string
Generate AUTH_SECRET with openssl rand -base64 32.
Things to watch out for:
- Auth.js automatically performs PKCE and state validation when
type: 'oidc'is set. - The
issuerfield triggers.well-known/openid-configurationdiscovery on startup. - Refresh token rotation requires adding
offline_accessto the scope and implementing ajwtcallback that calls the token endpoint when the access token has expired. - Do not expose
LUXID_CLIENT_SECRETto the browser - it must only exist in server-side environment variables. - When deploying to Vercel Edge Runtime, use Auth.js v5, which is Edge-compatible. v4 (NextAuth) requires the Node.js runtime.
React SPA (oidc-client-ts)
Library: oidc-client-ts (opens in a new tab) by authts. Browser-native OIDC client with PKCE support.
Install:
npm install oidc-client-ts
UserManager configuration:
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
export const userManager = new UserManager({
authority: 'https://login.luxid.lu',
client_id: import.meta.env.VITE_LUXID_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
// LuxID has no OIDC end_session_endpoint (no RP-initiated logout), so post_logout_redirect_uri is unused - clear the local session on sign-out instead.
// post_logout_redirect_uri: `${window.location.origin}/`,
response_type: 'code',
scope: 'openid profile email',
// Store the user object in session storage, not local storage:
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
// Automatic silent token renewal requires a silent_redirect_uri:
silent_redirect_uri: `${window.location.origin}/auth/silent-callback`,
automaticSilentRenew: true,
});
Callback page (/auth/callback):
import { userManager } from './userManager';
// Call this once on the callback route:
await userManager.signinRedirectCallback();
window.location.replace('/dashboard');
Critical security warning: Never store a refresh token in browser local storage or session storage. Local storage is accessible to any JavaScript on the page, including third-party scripts. If your application needs refresh tokens, implement the Backend For Frontend (BFF) pattern: the browser authenticates with your backend, the backend holds the refresh token server-side, and the browser only receives a short-lived access token via a secure,
HttpOnly,SameSite=Strictcookie.
Things to watch out for:
oidc-client-tsperforms PKCE and state validation on the callback automatically.- Silent renewal uses a hidden iframe to hit the authorisation endpoint with
prompt=none. This requires the user to have an active session atlogin.luxid.luand third-party cookie support in the browser - the latter is increasingly restricted by modern browsers. The BFF pattern avoids this dependency. client_secretmust NOT be included in browser-side configuration. LuxID supports public clients (no secret) for SPAs - confirm with LuxID that your Application is registered as a public client.- Always listen for
userManager.events.addUserSessionChangedto detect session termination by the IdP.
Angular (angular-auth-oidc-client)
Library: angular-auth-oidc-client (opens in a new tab) by Damien Bod. Angular-native OIDC module with full PKCE support.
Install:
npm install angular-auth-oidc-client
App configuration (app.config.ts with standalone components):
import { ApplicationConfig } from '@angular/core';
import {
provideAuth,
withAppInitializerAuthCheck,
} from 'angular-auth-oidc-client';
export const appConfig: ApplicationConfig = {
providers: [
provideAuth({
config: {
authority: 'https://login.luxid.lu',
redirectUrl: window.location.origin + '/auth/callback',
postLogoutRedirectUri: window.location.origin,
clientId: import.meta.env['NG_APP_LUXID_CLIENT_ID'] ?? '',
scope: 'openid profile email',
responseType: 'code',
silentRenew: true,
useRefreshToken: false, // set true only with a BFF pattern
renewTimeBeforeTokenExpiresInSeconds: 30,
},
}),
withAppInitializerAuthCheck(),
],
};
Auth guard example:
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
export const authGuard: CanActivateFn = () => {
const oidcService = inject(OidcSecurityService);
const router = inject(Router);
return oidcService.isAuthenticated$.pipe(
map(({ isAuthenticated }) =>
isAuthenticated || router.createUrlTree(['/login'])
)
);
};
Things to watch out for:
withAppInitializerAuthCheck()triggers an automatic silent check on app startup. Ensure the callback route is excluded from the auth guard.- PKCE is enabled by default. Do not override
codeChallengeMethod. - As with all SPA clients, never include a
client_secret. Register as a public client with LuxID. silentRenew: truewithuseRefreshToken: falseuses the iframe/prompt=noneapproach, which is affected by third-party cookie restrictions. Consider the BFF pattern for production.- To read user claims, subscribe to
oidcService.userData$.
React native (react-native-app-auth)
Library: react-native-app-auth (opens in a new tab) by Formidable. Wraps AppAuth-iOS and AppAuth-Android natively.
Install:
npm install react-native-app-auth
npx pod-install # iOS only
iOS - URL scheme (Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.yourcompany.yourapp</string>
</array>
</dict>
</array>
Android - Intent filter (AndroidManifest.xml):
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourcompany.yourapp" />
</intent-filter>
</activity>
Configuration and login flow:
import { authorize, refresh } from 'react-native-app-auth';
const config = {
issuer: 'https://login.luxid.lu',
clientId: 'YOUR_CLIENT_ID',
redirectUrl: 'com.yourcompany.yourapp:/auth/callback',
scopes: ['openid', 'profile', 'email'],
usePKCE: true,
};
// Authenticate
const authState = await authorize(config);
console.log('Access token:', authState.accessToken);
console.log('ID token:', authState.idToken);
// Refresh (requires offline_access scope and a refresh token)
const refreshedState = await refresh(config, {
refreshToken: authState.refreshToken,
});
Things to watch out for:
usePKCE: trueis the default and must remain enabled. LuxID does not support the implicit flow.- The redirect URI scheme must be registered with LuxID exactly as it appears in
redirectUrl(e.g.com.yourcompany.yourapp:/auth/callback). - On Android, use App Links (HTTPS redirect URIs) rather than custom schemes for production to prevent redirect hijacking. This requires domain verification via Digital Asset Links.
- On iOS, use Universal Links for the same reason.
- Store the refresh token in the platform's secure storage (
react-native-keychainorexpo-secure-store), never inAsyncStorage. react-native-app-authdoes not manage token refresh automatically. Implement a wrapper that callsrefresh()when the access token has expired.
iOS native (AppAuth-iOS)
Library: AppAuth-iOS (opens in a new tab) by the OpenID Foundation. Certified OIDC Relying Party implementation.
CocoaPods (Podfile):
pod 'AppAuth', '~> 1.7'
Swift - Discovery and authorisation request:
import AppAuth
let issuer = URL(string: "https://login.luxid.lu")!
// 1. Discover endpoints
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, error in
guard let config else {
print("Discovery failed: \(error!.localizedDescription)")
return
}
// 2. Build authorisation request (PKCE is automatic)
let request = OIDAuthorizationRequest(
configuration: config,
clientId: "YOUR_CLIENT_ID",
clientSecret: nil, // nil for public clients
scopes: [OIDScopeOpenID, OIDScopeProfile, "email"],
redirectURL: URL(string: "com.yourcompany.yourapp:/auth/callback")!,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
// 3. Perform authorisation
// currentAuthorizationFlow must be stored as a property on AppDelegate
AppDelegate.currentAuthorizationFlow =
OIDAuthState.authState(
byPresenting: request,
presenting: self
) { authState, error in
guard let authState else {
print("Authorisation failed: \(error!.localizedDescription)")
return
}
// Access token: authState.lastTokenResponse?.accessToken
// ID token: authState.lastTokenResponse?.idToken
}
}
URL handling in AppDelegate.swift:
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if let flow = Self.currentAuthorizationFlow,
flow.resumeExternalUserAgentFlow(with: url) {
Self.currentAuthorizationFlow = nil
return true
}
return false
}
Things to watch out for:
- PKCE is generated automatically by AppAuth-iOS. Do not bypass it.
- For iOS 11+, use
ASWebAuthenticationSession(the default in AppAuth 1.6+) rather thanSFSafariViewControllerfor improved security isolation. - Use Universal Links rather than custom URI schemes in production to prevent redirect hijacking - AppAuth supports both.
- Persist
OIDAuthStateusingNSSecureCodingto the iOS Keychain, notUserDefaults. OIDAuthState.withFreshTokens()handles silent refresh automatically when a refresh token is available.
Android native (AppAuth-android)
Library: AppAuth-Android (opens in a new tab) by the OpenID Foundation.
Gradle dependency (build.gradle):
implementation 'net.openid:appauth:0.11.1'
Endpoint discovery and authorisation:
import net.openid.appauth.*
AuthorizationServiceConfiguration.fetchFromIssuer(
Uri.parse("https://login.luxid.lu")
) { config, ex ->
if (config == null) {
Log.e("LuxID", "Discovery failed", ex)
return@fetchFromIssuer
}
val authRequest = AuthorizationRequest.Builder(
config,
BuildConfig.LUXID_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse("com.yourcompany.yourapp:/auth/callback")
)
.setScopes("openid", "profile", "email")
.setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
.build()
val authService = AuthorizationService(this)
val intent = authService.getAuthorizationRequestIntent(authRequest)
startActivityForResult(intent, RC_AUTH)
}
Token exchange (in onActivityResult):
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RC_AUTH) {
val resp = AuthorizationResponse.fromIntent(data!!)
val ex = AuthorizationException.fromIntent(data)
val authState = AuthState(resp, ex)
resp?.let {
val authService = AuthorizationService(this)
authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResp, tokenEx ->
authState.update(tokenResp, tokenEx)
// tokenResp?.accessToken
// tokenResp?.idToken
}
}
}
}
Redirect intent filter (AndroidManifest.xml):
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.yourcompany.yourapp" />
</intent-filter>
</activity>
Things to watch out for:
- Set
android:exported="true"onRedirectUriReceiverActivityfor Android 12+ (API 31+) - the manifest compiler enforces this. - Use App Links (HTTPS redirect URIs) with Digital Asset Links verification for production to prevent redirect hijacking by a malicious app.
- Serialise
AuthStatewithAuthState.jsonSerialize()and store in encrypted SharedPreferences or the Android Keystore, not plain SharedPreferences. AuthState.performActionWithFreshTokens()handles transparent refresh token renewal.- PKCE (
setCodeVerifier(...)) is handled by AppAuth automatically when you useAuthorizationRequest.Builder. Do not omit it.
PHP (jumbojett/openid-connect-php)
Library: jumbojett/openid-connect-php (opens in a new tab). Lightweight, widely deployed OIDC Relying Party for PHP 7.4+.
Install:
composer require jumbojett/openid-connect-php
Minimal integration:
<?php
require 'vendor/autoload.php';
use Jumbojett\OpenIDConnectClient;
session_start();
$oidc = new OpenIDConnectClient(
'https://login.luxid.lu',
getenv('LUXID_CLIENT_ID'),
getenv('LUXID_CLIENT_SECRET')
);
$oidc->setRedirectURL('https://yourapp.example.com/auth/callback');
$oidc->addScope(['openid', 'profile', 'email']);
// Enforce TLS verification - never set to false in production:
$oidc->setVerifyHost(true);
$oidc->setVerifyPeer(true);
// Enable PKCE:
$oidc->setCodeChallengeMethod('S256');
// Trigger login (redirects to LuxID):
$oidc->authenticate();
// After callback, claims are available:
$sub = $oidc->getVerifiedClaims('sub');
$email = $oidc->getVerifiedClaims('email');
$name = $oidc->getVerifiedClaims('name');
echo "Welcome, {$name} ({$email})";
Things to watch out for:
setVerifyHost(true)andsetVerifyPeer(true)must both be set. Disabling TLS verification voids the security of the entire flow.- The library stores the state and nonce in the PHP session. Ensure
session_start()is called beforeauthenticate(). getVerifiedClaims()returns claims from the ID token only. To fetch UserInfo endpoint claims, callrequestUserInfo()separately.- Check the library's GitHub for patches; it does not follow a predictable release cadence.
- PHP sessions must be stored server-side (Redis, database) in load-balanced deployments - file-based sessions break across nodes.
Python (authlib with flask)
Library: Authlib (opens in a new tab). Implements OIDC Core, PKCE, and token validation. Supports Flask and Django.
Install:
pip install authlib flask requests
Flask integration:
import os
from flask import Flask, redirect, url_for, session
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET_KEY']
oauth = OAuth(app)
oauth.register(
name='luxid',
server_metadata_url=(
'https://login.luxid.lu/.well-known/openid-configuration'
),
client_id=os.environ['LUXID_CLIENT_ID'],
client_secret=os.environ['LUXID_CLIENT_SECRET'],
client_kwargs={
'scope': 'openid profile email',
'code_challenge_method': 'S256', # enable PKCE
},
)
@app.route('/login')
def login():
redirect_uri = url_for('auth_callback', _external=True)
return oauth.luxid.authorize_redirect(redirect_uri)
@app.route('/auth/callback')
def auth_callback():
token = oauth.luxid.authorize_access_token()
user_info = token.get('userinfo')
session['user'] = {
'sub': user_info.get('sub'),
'email': user_info.get('email'),
'name': user_info.get('name'),
}
return redirect('/')
@app.route('/')
def index():
user = session.get('user')
if not user:
return redirect('/login')
return f"Hello {user['name']}"
Django integration (alternative):
Authlib also ships authlib.integrations.django_client. The configuration mirrors the Flask example - replace OAuth(app) with OAuth() and wire it into Django's URL configuration via path('auth/', include('authlib.integrations.django_client.urls')) or manually.
Things to watch out for:
server_metadata_urltriggers discovery on first request. Authlib caches the metadata with respect to theCache-Controlheader from LuxID's discovery endpoint.code_challenge_method: 'S256'enables PKCE. Do not omit it.authorize_access_token()validates the ID token signature, issuer, audience, nonce, and expiry automatically.- Use
flask-sessionwith a server-side backend (Redis, SQLAlchemy) in production. The default client-side cookie session has a 4 KB size limit, which the token set may exceed. - Never log the full
tokendict - it contains the access token and potentially the refresh token.