Aller au contenu principal
Version 0.3Brouillon

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, when offline_access is 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.

attention

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:

  1. Extract the Authorization: Bearer <token> header.
  2. Validate the token (see Token Validation below).
  3. Check that the required scope is present.
  4. Check any additional claims your API uses for authorisation (e.g., groups, assurance level).
  5. 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:

ScopeClaims includedWhen to request
openidsub, iss, aud, exp, iatAlways required for OIDC
profilegiven_name, family_name, name, nickname, updated_atWhen you need the user's name
emailemail, email_verifiedWhen you need the user's email address
phonephone_number, phone_number_verifiedWhen you need the user's phone number
offline_accessIssues a refresh tokenWhen 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:

  1. Request only the standard scopes you need.
  2. Include profile or email to retrieve the user identity claims you need.
  3. 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.
  4. 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:

MethodDescriptionUse when
client_secret_postclient_id and client_secret in the POST bodyStandard server-side integration
client_secret_basicHTTP Basic Auth with Base64-encoded client_id:client_secretLibraries 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

ClaimValidation
issMust equal https://login.luxid.lu
audMust include your Client ID or resource server identifier
expMust be in the future (current UTC time < exp)
iatShould be in the recent past (within a reasonable clock-skew window)
nbfIf 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, aud is the Client ID of the Relying Party.
  • In an access token, aud identifies 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.

attention

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:

PracticeRequirement
PKCERequired for all clients (public and confidential)
Implicit grantNever use. response_type=token is not supported at LuxID
Resource Owner Password CredentialsNever use. ROPC bypasses Universal Login and MFA
State parameterAlways send a cryptographically random state; validate on return
NonceSend a cryptographically random nonce in every authorisation request; validate in the ID token
Short-lived access tokensKeep access token lifetime short (minutes to 1 hour); use refresh tokens for continuity
Refresh token rotationLuxID rotates refresh tokens on use. Store the new token and discard the old one immediately
HTTPS onlyAll redirect URIs and API calls must use HTTPS. No HTTP in production
Redirect URI exact matchRegister 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.

Mise à jour le 2026-07-02