Key management
Overview
Every ID Token issued by LuxID is signed using an asymmetric key pair. Your application must verify this signature before trusting any claims in the token. This page explains:
- Which algorithm LuxID uses to sign tokens
- How to find the correct public key using the JWKS endpoint
- How to cache JWKS responses efficiently
- How key rotation works and how to handle it without service disruption
- How to keep your Client Secret secure
ID Token signing algorithm
LuxID signs ID Tokens using RS256 (RSA Signature with SHA-256), as defined in RFC 7518 (opens in a new tab) §3.3 (JSON Web Algorithms).
RS256 is an asymmetric algorithm:
- LuxID holds the private key and uses it to sign tokens
- Your application uses the corresponding public key to verify signatures
- The public key is published at the JWKS endpoint and can be fetched by anyone
- The private key never leaves LuxID's infrastructure
This means any OAuth client - public or confidential - can verify the signature without a shared secret. It does not change the token-storage guidance elsewhere in these docs: validate and hold the ID Token server-side, not in browser JavaScript (a browser-resident token is still exposed to XSS).
The JWKS endpoint
The JSON Web Key Set (JWKS) endpoint publishes LuxID's current signing public keys:
https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID
For UAT:
https://login-uat.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID
This endpoint also appears in the discovery document:
https://login.luxid.lu/.well-known/openid-configuration
Look for the jwks_uri field.
Example JWKS response
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "luxid-signing-key-001",
"alg": "RS256",
"n": "...",
"e": "AQAB"
},
{
"kty": "RSA",
"use": "sig",
"kid": "luxid-signing-key-002",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
Multiple keys may be published simultaneously to support rotation overlap (see §5).
The kid header
Every ID Token JWT contains a kid (Key ID) header field. This field identifies which key from the JWKS was used to sign the token. Use it to select the correct key before verifying the signature:
- Python
- Java
- PHP
- Node.js
- C#
import jwt # PyJWT example
# Decode the header without verifying (to extract kid)
header = jwt.get_unverified_header(id_token)
kid = header["kid"]
# Find the matching key in the cached JWKS
signing_key = next(
(key for key in jwks_keys if key["kid"] == kid),
None
)
if signing_key is None:
# Key not found - may have rotated; refresh JWKS and retry once
refresh_jwks_cache()
signing_key = next(
(key for key in jwks_keys if key["kid"] == kid),
None
)
if signing_key is None:
raise ValueError(f"Unknown signing key kid={kid}")
# Verify with the found key
claims = jwt.decode(
id_token,
signing_key,
algorithms=["RS256"],
audience="your-client-id",
issuer="https://login.luxid.lu"
)
// nimbus-jose-jwt selects the key by `kid` for you when you build the
// processor from a remote JWKS; the explicit lookup below mirrors the logic.
SignedJWT jwt = SignedJWT.parse(idToken);
String kid = jwt.getHeader().getKeyID();
JWK signingKey = jwksCache.getKeyByKeyId(kid);
if (signingKey == null) {
refreshJwksCache(); // may have rotated
signingKey = jwksCache.getKeyByKeyId(kid);
if (signingKey == null) {
throw new IllegalStateException("Unknown signing key kid=" + kid);
}
}
RSASSAVerifier verifier = new RSASSAVerifier(signingKey.toRSAKey());
if (!jwt.verify(verifier)) {
throw new BadJOSEException("Invalid signature");
}
JWTClaimsSet claims = jwt.getJWTClaimsSet(); // then check iss / aud / exp / nonce
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
// Decode the header without verifying (to extract kid)
[$headerB64] = explode('.', $idToken);
$header = json_decode(base64_decode(strtr($headerB64, '-_', '+/')), true);
$kid = $header['kid'];
$keys = JWK::parseKeySet($jwksCache); // keyed by kid
if (!isset($keys[$kid])) {
$jwksCache = refresh_jwks_cache(); // may have rotated
$keys = JWK::parseKeySet($jwksCache);
if (!isset($keys[$kid])) {
throw new RuntimeException("Unknown signing key kid={$kid}");
}
}
// JWT::decode enforces RS256 and selects the key by kid
$claims = JWT::decode($idToken, $keys);
import { createRemoteJWKSet, jwtVerify, decodeProtectedHeader } from 'jose';
// createRemoteJWKSet selects the key by `kid` and refreshes on a miss
// (with a cooldown), so the lookup/rotation handling is built in.
const JWKS = createRemoteJWKSet(
new URL('https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID')
);
const { kid } = decodeProtectedHeader(idToken); // available if you need it
const { payload } = await jwtVerify(idToken, JWKS, {
algorithms: ['RS256'],
issuer: 'https://login.luxid.lu',
audience: 'your-client-id',
});
// ConfigurationManager fetches and caches the JWKS; the handler selects the
// signing key by `kid` and refreshes automatically after rotation.
var config = await configManager.GetConfigurationAsync();
var parameters = new TokenValidationParameters
{
ValidIssuer = "https://login.luxid.lu",
ValidAudience = "your-client-id",
ValidAlgorithms = new[] { "RS256" },
IssuerSigningKeys = config.SigningKeys, // keyed by kid internally
};
var handler = new JsonWebTokenHandler();
var result = handler.ValidateToken(idToken, parameters);
if (!result.IsValid) throw result.Exception;
Most OIDC libraries handle this lookup automatically when configured with the jwks_uri.
Fetching and caching JWKS
Never hardcode the public key
Do not hardcode the RSA public key in your application. LuxID rotates signing keys periodically. An application that hardcodes the key will fail silently after rotation - tokens will fail signature verification with no clear error until a developer notices and updates the code.
Always fetch from the JWKS endpoint.
Cache the JWKS response
Fetching the JWKS on every token validation would be inefficient and would create an unnecessary dependency on JWKS endpoint availability during token processing. Cache the JWKS response with a reasonable TTL.
| Cache TTL | Trade-off |
|---|---|
| < 5 minutes | Very fresh, but increases load on JWKS endpoint and adds latency on cache miss |
| 5 - 15 minutes | Recommended for most applications |
| 15 - 60 minutes | Acceptable if key-not-found refresh (see §4.3) is implemented |
| > 1 hour | Risk of serving stale keys after rotation; only acceptable if rotation overlap is confirmed to be longer |
Refresh on key-not-found
When you receive an ID Token whose kid does not match any key in your cached JWKS, do not immediately reject the token. It may have been signed with a newly rotated key that your cache has not yet seen. Implement a "refresh once" pattern:
- Token arrives with
kidnot in cache - Fetch fresh JWKS from endpoint
- Update cache
- Look up
kidagain - If still not found, reject the token as using an unknown key
- If found, verify normally
This pattern ensures your application handles key rotation without downtime while still protecting against tokens signed with unknown keys.
- Python
- Java
- PHP
- Node.js
- C#
def get_signing_key(kid: str, force_refresh: bool = False) -> dict:
global jwks_cache, jwks_cache_timestamp
if force_refresh or cache_is_expired():
jwks_cache = fetch_jwks_from_endpoint()
jwks_cache_timestamp = now()
key = find_key_by_kid(jwks_cache, kid)
if key is None and not force_refresh:
# Try once more with a fresh fetch
return get_signing_key(kid, force_refresh=True)
return key # May be None if key is genuinely unknown
JWK getSigningKey(String kid, boolean forceRefresh) {
if (forceRefresh || cacheIsExpired()) {
jwksCache = fetchJwksFromEndpoint();
jwksCacheTimestamp = Instant.now();
}
JWK key = jwksCache.getKeyByKeyId(kid);
if (key == null && !forceRefresh) {
return getSigningKey(kid, true); // try once more with a fresh fetch
}
return key; // may be null if genuinely unknown
}
function getSigningKey(string $kid, bool $forceRefresh = false): ?array {
global $jwksCache, $jwksCacheTimestamp;
if ($forceRefresh || cacheIsExpired()) {
$jwksCache = fetchJwksFromEndpoint();
$jwksCacheTimestamp = time();
}
$key = findKeyByKid($jwksCache, $kid);
if ($key === null && !$forceRefresh) {
return getSigningKey($kid, true); // try once more with a fresh fetch
}
return $key; // may be null if genuinely unknown
}
async function getSigningKey(kid, forceRefresh = false) {
if (forceRefresh || cacheIsExpired()) {
jwksCache = await fetchJwksFromEndpoint();
jwksCacheTimestamp = Date.now();
}
const key = findKeyByKid(jwksCache, kid);
if (!key && !forceRefresh) {
return getSigningKey(kid, true); // try once more with a fresh fetch
}
return key; // may be undefined if genuinely unknown
}
async Task<JsonWebKey?> GetSigningKeyAsync(string kid, bool forceRefresh = false)
{
if (forceRefresh || CacheIsExpired())
{
_jwksCache = await FetchJwksFromEndpointAsync();
_jwksCacheTimestamp = DateTimeOffset.UtcNow;
}
var key = FindKeyByKid(_jwksCache, kid);
if (key is null && !forceRefresh)
return await GetSigningKeyAsync(kid, forceRefresh: true); // refresh once
return key; // may be null if genuinely unknown
}
Cache in a shared layer for multi-instance Applications
If your application runs as multiple instances (containerised deployment, horizontal scaling), cache the JWKS in a shared layer (Redis, Memcached, or a database) rather than in each instance's memory. This prevents unnecessary concurrent JWKS fetches on every deploy or scale event.
Key rotation
How LuxID rotates keys
LuxID rotates its signing keys periodically as part of routine key management hygiene. Rotation follows an overlap strategy:
- A new key pair is generated
- The new public key is added to the JWKS response (alongside the current key)
- After a period during which all applications have had time to refresh their JWKS caches, tokens begin being signed with the new key
- The old key remains in the JWKS response for a further period to allow any tokens signed with it to be validated during their remaining lifetime
- The old key is eventually removed from the JWKS response
The overlap means that any application implementing the key-not-found refresh pattern (§4.3) will handle rotation transparently. Applications that hardcode the key, or that cache without a key-not-found refresh, will break.
What Applications must do
- Implement JWKS caching with TTL (§4.2)
- Implement key-not-found refresh (§4.3)
- Never hardcode keys
No application-side configuration change is required for normal key rotation. The rotation is fully transparent if the above patterns are implemented.
Emergency rotation
In the event of a key compromise, LuxID may perform an emergency rotation where the old key is removed immediately without an overlap period. In this scenario:
- All tokens signed with the compromised key are invalidated immediately
- Affected users will need to re-authenticate
- Applications with a key-not-found refresh will quickly adapt
- Applications that do not refresh on key-not-found will reject all new tokens until their cache expires
Monitor your application's token validation error rates. A sudden spike in signature verification failures is a signal that an emergency rotation may have occurred. Reduce your JWKS TTL or implement cache invalidation hooks for high-availability requirements.
Client authentication - keeping your client secret secure
In addition to LuxID's signing keys (used to verify ID Tokens), your application may have its own credentials for authenticating to LuxID's token endpoint. These are distinct from LuxID's signing keys.
Client secret
Confidential clients authenticate to LuxID's token endpoint using a Client Secret - a high-entropy random value provisioned when you register your application. See Protect your Application §9 for storage and rotation guidance.
Rotation is performed by request to LuxID. See Client credentials for the field reference.
Summary
| Topic | Guidance |
|---|---|
| Signing algorithm | RS256 |
| JWKS endpoint (production) | https://login.luxid.lu/mga/sps/oauth/oauth20/jwks/OIDC-LUXID |
| JWKS caching TTL | 5 - 60 minutes, with key-not-found refresh |
| Hardcoded public keys | Never - always fetch from JWKS |
| Key rotation handling | Implement key-not-found refresh; no manual action required for normal rotation |
| Client secret rotation | By request to LuxID; see Client Credentials page |
| Client Secret storage | Secrets manager or environment variable injection; never in source code |
Further reading
- RFC 7517 (opens in a new tab) - JSON Web Key (JWK)
- RFC 7518 (opens in a new tab) - JSON Web Algorithms (JWA)
- RFC 7519 (opens in a new tab) - JSON Web Token (JWT)
- RFC 7515 (opens in a new tab) - JSON Web Signature (JWS)
- OpenID Connect Core 1.0 §3.1.3.7 (opens in a new tab) - ID Token Validation
- Advanced security options - PAR and JAR (not currently implemented)
- Client credentials - rotating client secrets via Console
- Token validation issues - debugging signature failures