Skip to main content

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​

  1. Signature: verify against the keys at jwks_uri from discovery (GET /orgs/{orgId}/api/v1/.well-known/jwks.json).
  2. Issuer: iss must equal the discovery issuer byte-for-byte. See Validate the issuer.
  3. Audience and time: aud must contain your API or client, and exp must 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​

AlgorithmKey type
RS256RSA 2048
RS384RSA 2048
RS512RSA 2048
ES256EC P-256
ES384EC P-384
ES512EC P-521
PS256RSA-PSS
PS384RSA-PSS
PS512RSA-PSS

Standard JWT Claims​

ClaimDescription
issIssuer. 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.
subSubject — user or agent ID
audAudience — client ID or resource server
expExpiration time (Unix timestamp)
iatIssued-at time
jtiUnique token ID
client_idOAuth client that requested the token
scopeSpace-separated granted scopes

Extension Claims​

ClaimDescription
rolesArray of role names. Only when the client explicitly enables User roles in its JWT claims; see Claims and roles
permissionsArray of permission slugs (requires permissions scope)
email, email_verifiedUser email (requires email scope)
name and other profile claimsRequire the profile scope
phone_number, addressRequire the phone / address scope
organizationOrganization 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.

Decoded JWT Access Token
{
"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 pointissuer / 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
});
Standard

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.

DPoP Token Request
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:

EndpointResponse without a fresh nonceRetry
Token endpoint400 {"error":"use_dpop_nonce"} + DPoP-Nonce headerNew 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 typeDefault lifetime
Access token3600 s (1 hour)
ID token3600 s (1 hour)
Refresh token2592000 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 code600 s (10 min); 60 s for FAPI 2.0 clients

Standards used on this page​

Plain nameStandard
JSON Web Key (JWK) and key sets (JWKS)RFC 7517
JWT access token profileRFC 9068
JSON Web Token (JWT)RFC 7519
OpenID Connect (OIDC)OpenID Connect Core 1.0
Token introspectionRFC 7662
Sender-constrained tokens (DPoP)RFC 9449
Financial-grade API (FAPI) 2.0FAPI 2.0 Security Profile

See the standards reference for what each one covers and how LumoAuth uses it.