Token Formats
LumoAuth issues access tokens in two formats: JWT (default) and opaque. JWTs are self-contained and can be validated offline by any resource server holding the issuer's public key; opaque tokens are random strings that must be validated by calling the introspection endpoint.
Validate a JWT in three checks
- Signature: verify against the keys at
jwks_urifrom discovery (GET /orgs/{orgId}/api/v1/.well-known/jwks.json). - Issuer:
issmust equal the discoveryissuerbyte-for-byte. See Validate the issuer. - Audience and time:
audmust contain your API or client, andexpmust be in the future.
JWT Access Tokens
JWT access tokens follow the JWT access token profile, the standard set of claims (iss, aud, client_id, scope, …) for access tokens issued as JSON Web Tokens, with the JOSE header typ: at+jwt. A JWT (a signed JSON payload) can be verified offline with the issuer's public key from the JWKS endpoint (the list of public keys used to verify tokens).
JWKS endpoint:
GET /orgs/{orgId}/api/v1/.well-known/jwks.json
Supported Signing Algorithms
| Algorithm | Key type |
|---|---|
RS256 | RSA 2048 |
RS384 | RSA 2048 |
RS512 | RSA 2048 |
ES256 | EC P-256 |
ES384 | EC P-384 |
ES512 | EC P-521 |
PS256 | RSA-PSS |
PS384 | RSA-PSS |
PS512 | RSA-PSS |
Standard JWT Claims
| Claim | Description |
|---|---|
iss | Issuer. https://app.lumoauth.dev/orgs/{orgId}/api/v1 on the organization path, or https://auth.acme.com (bare domain) when minted on a verified custom domain. Identical to the discovery issuer. |
sub | Subject — user or agent ID |
aud | Audience — client ID or resource server |
exp | Expiration time (Unix timestamp) |
iat | Issued-at time |
jti | Unique token ID |
client_id | OAuth client that requested the token |
scope | Space-separated granted scopes |
Extension Claims
| Claim | Description |
|---|---|
roles | Array of role names. Only when the client explicitly enables User roles in its JWT claims; see Claims and roles |
permissions | Array of permission slugs (requires permissions scope) |
email, email_verified | User email (requires email scope) |
name and other profile claims | Require the profile scope |
phone_number, address | Require the phone / address scope |
organization | Organization slug |
User claims follow the granted scopes: the client's JWT-claims settings can remove a claim, never add one the scopes don't cover.
{
"iss": "https://app.lumoauth.dev/orgs/acme-corp/api/v1",
"sub": "01JF3KABCDE...",
"aud": ["https://api.example.com"],
"exp": 1753920000,
"iat": 1753916400,
"jti": "01JF3KXYZ...",
"client_id": "app_client_01jf3k...",
"scope": "openid profile email roles",
"email": "john@acme.com",
"roles": ["admin"],
"organization": "acme-corp"
}
Validate the issuer
The iss claim of every token LumoAuth mints (access, ID and logout tokens), the iss field in introspection responses and the iss parameter on authorization responses are all produced from one derivation, so one comparison covers them all:
| Entry point | issuer / iss |
|---|---|
https://app.lumoauth.dev/orgs/acme-corp/... | https://app.lumoauth.dev/orgs/acme-corp/api/v1 |
https://auth.acme.com/... (verified custom domain) | https://auth.acme.com |
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Fetch once at startup from the entry point your app uses.
const discovery = await (await fetch(
'https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/openid-configuration'
)).json();
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
const { payload } = await jwtVerify(accessToken, jwks, {
issuer: discovery.issuer, // exact string comparison
audience: 'https://api.example.com', // your resource identifier
});
OpenID Connect Core 3.1.3.7 requires the ID token iss to exactly match the issuer from discovery, and the JWT access token profile requires the same for JWT access tokens. To comply, pass the discovery issuer to your JWT library's issuer option and never turn issuer validation off.
Migration note. Tokens minted before 16 September 2026 carried https://app.lumoauth.dev/orgs/acme-corp (no /api/v1). LumoAuth's own verifiers (introspection, userinfo, the admin API) accept that legacy value while those tokens expire. New tokens always carry the discovery value, so a resource server that validates against discovery needs no special handling; one that hard-coded the old string must switch to the discovery value.
Opaque Access Tokens
Opaque tokens are random strings with no embedded claims. They must be validated via the Introspection Endpoint.
DPoP
LumoAuth supports DPoP-bound (sender-constrained) access tokens to prevent token replay. DPoP binds an access token to a client-held key: each request includes a short-lived proof JWT signed with that key, so a stolen token alone is useless from another host.
Include a DPoP header with a signed JWT proof in all requests when using DPoP tokens, and present the token as Authorization: DPoP <token> rather than Bearer.
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...}" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "..."
Server-provided nonces
By default LumoAuth validates the proof's htm/htu/iat/jti and rejects replays per key. A proof presented to a protected resource such as /userinfo is validated exactly once per request, so a proof that the firewall already checked is not reported as a replay.
Organizations can additionally require a server-provided nonce in every proof by setting settings.security.dpop_require_nonce to true (via PATCH /orgs/{orgId}/api/v1/admin/organization, see the organization API). When enabled:
| Endpoint | Response without a fresh nonce | Retry |
|---|---|---|
| Token endpoint | 400 {"error":"use_dpop_nonce"} + DPoP-Nonce header | New proof with nonce = header value, fresh jti and iat |
Protected resources (/userinfo) | 401 {"error":"use_dpop_nonce"} + DPoP-Nonce header + WWW-Authenticate: DPoP error="use_dpop_nonce" | Same |
Nonces are single-use and scoped (per client_id at the token endpoint, per organization at resources). Enable it only for clients that implement the retry; it is off by default.
Early invalidation
A token stops working before exp when its user is blocked, deactivated or deleted, signs out everywhere (or an admin revokes all their sessions), has their password changed, reset or set by an admin, or when a Shared Signals session-revoked or credential-compromise event arrives for them. Every access token issued before that moment, JWT or opaque, is rejected by introspection (active: false), UserInfo and LumoAuth's own APIs, and refresh tokens from before it get invalid_grant. A resource server that validates JWTs offline cannot see this; introspect when you need immediate revocation.
Token Lifetimes
Default token lifetimes (the access and refresh token lifetimes are configurable per client in the portal under Applications → your app → Security → Token settings → Lifetimes):
| Token type | Default lifetime |
|---|---|
| Access token | 3600 s (1 hour) |
| ID token | 3600 s (1 hour) |
| Refresh token | 2592000 s (30 days), settable from 1 day to 1 year; issued only to clients with the refresh_token grant |
| Refresh token family (absolute) | 90 days, or the client's refresh-token lifetime if longer; not extended by rotation |
| Authorization code | 600 s (10 min); 60 s for FAPI 2.0 clients |
Related
- Introspection — required for opaque tokens, optional for JWTs
- FAPI 2.0 — DPoP and sender-constrained tokens
- Resource indicators — set the
audclaim on issued tokens - Integrator checklist
Standards used on this page
| Plain name | Standard |
|---|---|
| JSON Web Key (JWK) and key sets (JWKS) | RFC 7517 |
| JWT access token profile | RFC 9068 |
| JSON Web Token (JWT) | RFC 7519 |
| OpenID Connect (OIDC) | OpenID Connect Core 1.0 |
| Token introspection | RFC 7662 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
| Financial-grade API (FAPI) 2.0 | FAPI 2.0 Security Profile |
See the standards reference for what each one covers and how LumoAuth uses it.