OAuth 2.0 for APIs
Overview
OAuth 2.0 protects APIs by issuing bearer access tokens (RFC 6749 (opens in a new tab), RFC 6750 (opens in a new tab)) that callers present to your resource server. The server validates the token, checks scopes and claims, and grants or denies access - without ever handling the user's credentials.
LuxID implements OAuth 2.0 as the authorisation layer underneath OpenID Connect. When your application completes the Authorization Code + PKCE flow, LuxID issues three artefacts:
- an ID token (
id_token) - an OIDC identity assertion, consumed by your application - an access token (
access_token) - an OAuth 2.0 credential, presented to your API - a refresh token (
refresh_token, whenoffline_accessis requested) - used to obtain fresh access tokens without re-prompting the user
This document focuses on the access token and how to use it to protect your backend APIs. For the full OIDC login flow, see the Authorization Code + PKCE guide. For token structure and claims, see Tokens and claims.
Bearer token basics
An access token is a bearer credential: whoever holds it can use it.
Your API must validate every incoming token - never trust a token without verification.
Sending a bearer token
Clients pass the access token in the HTTP Authorization header using the Bearer scheme (RFC 6750 §2.1 (opens in a new tab)):
GET /api/v1/profile HTTP/1.1
Host: api.example.lu
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Do not pass tokens in query parameters or the request body. Those methods are permitted by RFC 6750 for legacy reasons only - they expose tokens in server logs and browser history.
What your resource server must do
On every protected request:
- Extract the
Authorization: Bearer <token>header. - Validate the token (see Token Validation below).
- Check that the required scope is present.
- Check any additional claims your API uses for authorisation (e.g., groups, assurance level).
- Serve the response, or return
401 Unauthorized/403 Forbidden.
Return WWW-Authenticate: Bearer realm="api.example.lu" on 401 responses, as required by RFC 6750 §3.
Scopes at LuxID
Scopes are space-delimited strings that your application requests at the authorisation endpoint. LuxID supports the following scopes for user-facing flows:
| Scope | Claims included | When to request |
|---|---|---|
openid | sub, iss, aud, exp, iat | Always required for OIDC |
profile | given_name, family_name, name, nickname, updated_at | When you need the user's name |
email | email, email_verified | When you need the user's email address |
phone | phone_number, phone_number_verified | When you need the user's phone number |
offline_access | Issues a refresh token | When you need tokens after the browser session ends |
Requesting scopes
Include scopes as a space-separated value in the scope parameter at the authorisation endpoint:
GET /mga/sps/oauth/oauth20/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.lu%2Fcallback
&scope=openid%20profile%20email%20offline_access
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM_STATE
HTTP/1.1
Host: login.luxid.lu
LuxID presents a consent screen listing the requested scopes. The user may decline individual scopes if the application is not a trusted first-party client.
Custom scopes and fine-grained authorisation
LuxID does not currently support arbitrary custom scopes per Application (e.g., read:orders, write:invoices). The supported scopes are fixed to the standard OIDC set listed above.
Beyond the absence of custom scopes, there is a second constraint that differs from vanilla OIDC: claim release is pinned by your Application's Claim Template on the LuxID side, and the scope parameter you send does not gate it. Claim entitlement is set at configuration time by attaching a Claim Template to your Application, reviewed and approved by LuxID before activation. The scope parameter must still satisfy the OIDC spec (openid at minimum) but does not unlock additional claims - those require a Claim Template update via a Partner request to LuxID. This is intentional: LuxID enforces data minimisation at the platform level, not by trusting the Partner to self-limit at runtime.
For fine-grained API authorisation, the recommended pattern is:
- Request only the standard scopes you need.
- Include
profileoremailto retrieve the user identity claims you need. - Enforce your own permission model in your API, based on claims such as
sub(stable user identifier), group membership (if LuxID surfaces group claims for your integration), or assurance level. - Do not attempt to use LuxID as a policy engine for your internal API roles - LuxID governs identity; your API governs access.
Client types: confidential vs public
RFC 6749 §2.1 (opens in a new tab) distinguishes two client types. The distinction determines which token_endpoint_auth_method you use and whether you can hold a client secret securely.
Confidential clients
Server-side applications that can store a secret securely: a web server, a backend service, or a worker process. LuxID supports the following authentication methods for confidential clients:
| Method | Description | Use when |
|---|---|---|
client_secret_post | client_id and client_secret in the POST body | Standard server-side integration |
client_secret_basic | HTTP Basic Auth with Base64-encoded client_id:client_secret | Libraries that default to Basic Auth |
Both methods are equivalent in security terms. Choose based on which your HTTP library supports most cleanly.
Public clients
Single-page applications (SPAs) and native mobile apps cannot store a secret. These clients:
- Use PKCE (RFC 7636 (opens in a new tab)) to prove code ownership without a client secret.
- Do not authenticate at the token endpoint with a
client_secret. - Should use short-lived access tokens and store refresh tokens securely: in memory for SPAs; in the platform secure enclave or keychain for mobile apps.
Token validation
Your resource server must validate every access token before trusting it. LuxID access tokens are JWTs signed with RS256. Follow these steps on every inbound request.
Fetch the JWKS
Retrieve LuxID's public signing keys from the JWKS endpoint:
https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID
Cache the key set aggressively and refresh it only when you encounter an unknown kid in a token header. Fetching the JWKS on every request is inefficient and may trigger rate limits.
Verify the signature
Use a JWT library that supports RS256. Match the kid from the token header to the correct key in the JWKS, then verify the signature. Reject any token whose signature does not verify.
Validate standard claims
| Claim | Validation |
|---|---|
iss | Must equal https://login.luxid.lu |
aud | Must include your Client ID or resource server identifier |
exp | Must be in the future (current UTC time < exp) |
iat | Should be in the recent past (within a reasonable clock-skew window) |
nbf | If present, current time must be greater than or equal to nbf |
Check required scopes
The scope claim (space-separated string) must include every scope your endpoint requires. Return 403 Forbidden with a WWW-Authenticate error of insufficient_scope if the required scope is absent.
Use introspection as an alternative
If your resource server cannot perform local JWT validation, or if you need authoritative revocation status, use token introspection (RFC 7662 (opens in a new tab)) instead of local validation. Full reference: Token introspection.
Audience and multi-API protection
The aud (audience) claim identifies the intended recipient(s) of a token.
- In an ID token,
audis the Client ID of the Relying Party. - In an access token,
audidentifies the resource server(s) authorised to accept it.
Your resource server must reject any token where aud does not include its own identifier. This prevents token confusion attacks where a token issued for one API is replayed against another.
Single API
Register one LuxID Application for your API. Your resource server validates that aud matches its own identifier. Tokens issued for other Applications are rejected outright.
Multiple APIs
If multiple logical APIs must accept tokens from the same client application, two patterns are available:
Option A - One Application per API. Each API has its own Client ID and validates aud against its own identifier. The client requests tokens separately for each API. This provides the cleanest separation and is recommended for APIs with different sensitivity levels or ownership teams.
Option B - Shared Application with scope routing. A single Application covers multiple APIs. Each API validates the shared aud and then checks for its required scope. This reduces the number of token requests but provides less isolation.
Resource indicators (RFC 8707)
RFC 8707 (opens in a new tab) defines a resource parameter that lets clients specify the target resource in the authorisation request, so the authorisation server can issue tokens scoped to a specific API. This would make Option A seamless for clients holding multiple resource tokens - but LuxID support for it is not confirmed: check resource_parameter_supported in the discovery document before relying on it.
Token revocation
Access tokens should be short-lived. When a user logs out or revokes consent, refresh tokens must be revoked immediately to prevent further use.
LuxID exposes a revocation endpoint compliant with RFC 7009 (opens in a new tab):
https://login.luxid.lu/mga/sps/oauth/oauth20/revoke
Send a POST request with the token and your client credentials:
curl --request POST \
--url "https://login.luxid.lu/mga/sps/oauth/oauth20/revoke" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data "token=REFRESH_TOKEN_TO_REVOKE" \
--data "token_type_hint=refresh_token" \
--data "client_id=YOUR_CLIENT_ID" \
--data "client_secret=YOUR_CLIENT_SECRET"
A successful revocation returns 200 OK with an empty body. Per RFC 7009, the server returns 200 OK even if the token was already invalid - do not treat this as an error. Full reference: Token revocation.
Machine-to-machine flows
The LuxID user-facing OIDC tenant supports only authorization_code and refresh_token grant types - both require a human user to complete the authorisation flow. The Client Credentials grant (client_credentials), which allows a service to authenticate as itself without a user, is not available in the standard OIDC tenant.
For true M2M scenarios - service-to-service calls, background workers, administrative automation - use the Partner API, which provides its own authentication mechanism negotiated during the LuxID Partner onboarding process. See Partner APIs and Advanced Integration.
Do not attempt to use Client Credentials against the OIDC token endpoint. It will fail with unsupported_grant_type.
Security best practices
All LuxID integrations should follow RFC 9700 (opens in a new tab) - OAuth 2.0 Security Best Current Practice. The table below summarises the key requirements:
| Practice | Requirement |
|---|---|
| PKCE | Required for all clients (public and confidential) |
| Implicit grant | Never use. response_type=token is not supported at LuxID |
| Resource Owner Password Credentials | Never use. ROPC bypasses Universal Login and MFA |
| State parameter | Always send a cryptographically random state; validate on return |
| Nonce | Send a cryptographically random nonce in every authorisation request; validate in the ID token |
| Short-lived access tokens | Keep access token lifetime short (minutes to 1 hour); use refresh tokens for continuity |
| Refresh token rotation | LuxID rotates refresh tokens on use. Store the new token and discard the old one immediately |
| HTTPS only | All redirect URIs and API calls must use HTTPS. No HTTP in production |
| Redirect URI exact match | Register exact redirect URIs; no wildcard or open redirectors |
For a comprehensive treatment, see Protect your Application.
Code examples
Calling a protected API with a bearer token (cURL)
After completing the token exchange, call your API with the access token in the Authorization header:
ACCESS_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
curl --request GET \
--url "https://api.example.lu/v1/profile" \
--header "Authorization: Bearer ${ACCESS_TOKEN}" \
--header "Accept: application/json"
Expected success response:
{
"sub": "abc123def456",
"given_name": "Marie",
"family_name": "Dupont",
"email": "marie.dupont@example.lu"
}
If the token is expired or invalid, expect a 401 with the WWW-Authenticate header:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.example.lu",
error="invalid_token",
error_description="The access token is expired"
Content-Type: application/json
{"error":"invalid_token","error_description":"The access token is expired"}
Validating a bearer token in node.js
This middleware validates an incoming access token using the LuxID JWKS endpoint. It uses jose (opens in a new tab), a widely audited JWT library for Node.js:
import { createRemoteJWKSet, jwtVerify } from 'jose';
import express from 'express';
const JWKS_URI = 'https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID';
const ISSUER = 'https://login.luxid.lu';
const AUDIENCE = 'YOUR_CLIENT_ID'; // replace with your Application's Client ID
// createRemoteJWKSet caches keys and re-fetches only on unknown kid
const jwks = createRemoteJWKSet(new URL(JWKS_URI));
async function requireBearerToken(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401)
.set('WWW-Authenticate', 'Bearer realm="api.example.lu"')
.json({
error: 'missing_token',
error_description: 'Authorization header with Bearer token required',
});
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['RS256'],
});
// Attach validated claims to the request for downstream handlers
req.auth = payload;
next();
} catch (err) {
return res.status(401)
.set('WWW-Authenticate', `Bearer realm="api.example.lu", error="invalid_token"`)
.json({
error: 'invalid_token',
error_description: err.message,
});
}
}
// Example: scope enforcement helper
function requireScope(scope) {
return (req, res, next) => {
const tokenScopes = (req.auth.scope || '').split(' ');
if (!tokenScopes.includes(scope)) {
return res.status(403)
.set('WWW-Authenticate',
`Bearer realm="api.example.lu", error="insufficient_scope", scope="${scope}"`)
.json({
error: 'insufficient_scope',
error_description: `Scope '${scope}' is required`,
});
}
next();
};
}
const app = express();
// Protected route: requires a valid token with the 'profile' scope
app.get('/api/v1/profile',
requireBearerToken,
requireScope('profile'),
(req, res) => {
res.json({ sub: req.auth.sub });
}
);
app.listen(3000);
Token introspection (node.js)
When local JWT validation is not appropriate - for example, when you need to verify that a refresh token has not been revoked:
import fetch from 'node-fetch';
const INTROSPECT_URI = 'https://login.luxid.lu/mga/sps/oauth/oauth20/introspect';
const CLIENT_ID = 'YOUR_CLIENT_ID';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
async function introspectToken(token) {
const credentials = Buffer
.from(`${CLIENT_ID}:${CLIENT_SECRET}`)
.toString('base64');
const response = await fetch(INTROSPECT_URI, {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ token }),
});
if (!response.ok) {
throw new Error(`Introspection endpoint returned ${response.status}`);
}
const result = await response.json();
// RFC 7662: result.active is the only field guaranteed to be present
if (!result.active) {
throw new Error('Token is inactive or revoked');
}
return result; // contains sub, scope, exp, client_id, etc. when active
}
Full endpoint reference: Token introspection.
Refresh Token lifecycle
When offline_access is included in the scope, LuxID issues a refresh token alongside the access token. Use it to obtain a new access token when the current one expires, without re-prompting the user:
curl --request POST \
--url "https://login.luxid.lu/mga/sps/oauth/oauth20/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data "grant_type=refresh_token" \
--data "refresh_token=YOUR_REFRESH_TOKEN" \
--data "client_id=YOUR_CLIENT_ID" \
--data "client_secret=YOUR_CLIENT_SECRET"
Successful response:
{
"access_token": "NEW_ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "NEW_REFRESH_TOKEN",
"scope": "openid profile email offline_access"
}
LuxID uses refresh token rotation: each use of a refresh token invalidates the old token and issues a new one. Store the new refresh_token immediately and discard the previous value. If a spent (already-rotated) refresh token is presented, LuxID may revoke the entire token family as a security measure against token theft.
Summary
- Use
Authorization: Bearer <access_token>to call protected APIs. - LuxID issues access tokens as part of the standard Authorization Code + PKCE flow.
- Validate tokens locally using the JWKS endpoint; use introspection when authoritative revocation status is needed.
- Standard scopes only:
openid,profile,email,phone,offline_access. No custom scopes. - Enforce fine-grained access control using claims on your resource server, not via LuxID.
- Client Credentials (M2M without a user) is not available in the OIDC tenant - use the Partner API instead.
- Follow RFC 9700 BCP: PKCE everywhere, no implicit grant, no ROPC, short-lived tokens with rotation.
Related documentation
- OpenID Connect - the login flow that produces your access token
- Tokens and claims - token structure and claim reference
- Token introspection - server-side token validation
- Token revocation - revoking access and refresh tokens
- Protect your Application - security hardening guide
- Partner APIs and Advanced Integration - M2M and service-to-service flows