Integrator checklist
Work through this list once per application. Every item is something that LumoAuth enforces or emits. Skipping one is the difference between a spec-compliant client and one that can be phished, replayed, or pointed at the wrong server.
The checklist
| # | Check | Why | Where |
|---|---|---|---|
| 1 | Read discovery (/.well-known/openid-configuration) at startup; never hard-code endpoints or the issuer | Endpoints and the issuer differ between the organization path and a custom domain | OAuth 2.0 API |
| 2 | Validate iss on the authorization response and on every token, byte-for-byte against the discovery issuer; never disable issuer checks in your library | Mix-up attacks (the issuer identifier in the response), token substitution (OIDC Core 3.1.3.7) | Validate the callback, Validate the issuer |
| 3 | Register exact redirect URIs; https for web apps, loopback http or a private-use scheme for native apps, never javascript:/data:/file: | Exact matching is what makes the code unusable by an attacker's site | Redirect URI rules |
| 4 | Use PKCE with S256 on every authorization request | Stops code interception; plain is refused | PKCE |
| 5 | Send and verify state (128 bits of randomness, bound to the browser session) | CSRF on the callback | Authorization endpoint |
| 6 | Send nonce for OIDC and check it in the ID token | ID token replay | Authorization endpoint |
| 7 | Verify token signatures against jwks_uri, check aud, exp, and typ: at+jwt for access tokens | Forged or misdirected tokens | Token formats |
| 8 | Use DPoP for public clients and for anything handling sensitive data; implement the use_dpop_nonce retry | A stolen bearer token is usable from anywhere; a DPoP token is not | Token endpoint |
| 9 | Prefer private_key_jwt or tls_client_auth over shared secrets for confidential clients | Secrets leak through logs and config files; keys do not | FAPI 2.0 |
| 10 | Store the client secret once; it is hashed server-side and never shown again. Rotate instead of "looking it up" | Nothing to recover if the copy is lost | OAuth clients |
| 11 | Handle invalid_client as "unknown or disabled" | Disabled clients are refused on every OAuth endpoint | Inactive clients |
| 12 | Register a back-channel logout URI and validate the logout token like an ID token | Single sign-out that does not depend on the browser | Logout |
| 13 | Use end_session_endpoint for sign-out, not the hosted /logout route | /logout needs a CSRF token that only LumoAuth pages have | Signing out of the hosted session |
| 14 | Keep tokens out of localStorage and out of cookies without HttpOnly + SameSite | XSS turns stored tokens into a full account takeover | Token storage |
Recommended Configuration
| Setting | Public clients (SPA / mobile) | Confidential clients (server) |
|---|---|---|
| Grant type | authorization_code | authorization_code, client_credentials |
| PKCE | ✅ Required (S256) | ✅ Recommended |
state | ✅ Required | ✅ Required |
nonce | ✅ Required for OIDC | ✅ Required for OIDC |
| Client auth | none | private_key_jwt or tls_client_auth; client_secret_basic at minimum |
| Token binding | DPoP recommended | DPoP recommended |
| PAR | Recommended | Required for FAPI |
PKCE (Proof Key for Code Exchange): the client generates a random secret (the code verifier) and sends a hash of it when starting the authorization flow. It then sends the real secret when exchanging the code. DPoP (sender-constrained tokens): binds an access token to a client-held key so that a stolen token alone is useless from another host. PAR (pushed authorization requests): the client POSTs the authorization parameters to a back-channel endpoint first, receiving a request_uri that it then uses in the browser redirect.
Issuer validation
The one string to compare against is the discovery issuer of the entry point that your application uses:
| Entry point | issuer |
|---|---|
| Organization path | https://app.lumoauth.dev/orgs/acme-corp/api/v1 |
| Verified custom domain | https://auth.acme.com |
That value appears as the iss parameter on authorization responses (success and error), the iss claim of access, ID and logout tokens, and the iss field of introspection responses. The pre-September-2026 form without /api/v1 is still accepted by LumoAuth's own verifiers while old tokens expire, but is no longer minted.
The issuer identifier in the response and OpenID Connect Core 3.1.3.7: compare iss with the discovery issuer before trusting a code or a token. Most libraries do this when you pass issuer; the mistake is passing the wrong string or turning the check off to "make it work".
Redirect URI Validation
LumoAuth performs exact-match validation on redirect_uri:
- Wildcards, prefix matches and variable loopback ports are not permitted; register every URI that you use.
- Web clients must use
https. Native clients (application type Native) may use loopbackhttp(127.0.0.1,localhost,[::1]) or a private-use scheme such ascom.example.app:/callback(as the native-apps best practice recommends). javascript:,data:,vbscript:,blob:,file:andabout:URIs are refused at registration time in the portal, the admin API and dynamic client registration. Presentation URIs (client_uri,logo_uri,policy_uri,tos_uri) must behttp(s).response_mode=form_postis only delivered tohttp(s)redirect URIs.
Token Storage
| Token | Recommended storage |
|---|---|
| Access token (SPA) | In-memory only |
| Refresh token (SPA) | Avoid; use silent auth, or a BFF that holds the refresh token server-side |
| Access token (mobile) | Secure enclave / Keychain |
| Refresh token (mobile) | Secure enclave / Keychain |
| Access token (server) | In-memory / short-lived cache |
| Refresh token (server) | Encrypted database |
Never store tokens in localStorage or in cookies without HttpOnly + SameSite=Strict flags.
Clients that don't need refresh tokens should leave refresh_token out of their grant types: LumoAuth then issues none. A refresh-token chain has an absolute lifetime (90 days, or the client's refresh-token lifetime if longer), so plan for a periodic interactive sign-in. See Refresh tokens.
Revocation and offline validation
A user's existing tokens are invalidated immediately at LumoAuth (introspection, UserInfo, LumoAuth APIs, refresh) when the user is blocked, deactivated or deleted. The same happens when they are signed out everywhere or have their password changed or reset. A resource server that only verifies JWT signatures offline keeps accepting such a token until exp. Keep access-token lifetimes short, or introspect for sensitive operations.
Claims
Request only the scopes that you need: user claims in access tokens follow the granted OIDC scopes (email, profile, phone, address), and roles must be enabled per client. Don't rely on email or roles being present in an access token unless you requested the scope or enabled the claim. See Claims and roles.
CSRF Protection
Always include and verify the state parameter. The state value must:
- Be cryptographically random (≥ 128 bits of entropy).
- Be bound to the user's session on your side.
- Be verified before processing the authorization response.
Related
Standards used on this page
| Plain name | Standard |
|---|---|
| Discovery document | RFC 8414 and OpenID Connect Discovery |
| Issuer identifier in the response | RFC 9207 |
| OpenID Connect (OIDC) | OpenID Connect Core 1.0 |
| Proof Key for Code Exchange (PKCE) | RFC 7636 |
| JWT access token profile | RFC 9068 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
| JWT client authentication and the JWT bearer grant | RFC 7523 |
| Mutual TLS client authentication | RFC 8705 |
| Logout | OpenID Connect Back-Channel Logout and RP-Initiated Logout |
| Pushed authorization requests (PAR) | RFC 9126 |
| OAuth for native apps | RFC 8252 |
| Token introspection | RFC 7662 |
See the standards reference for what each one covers and how LumoAuth uses it.