Use Seamless Auth As An OpenID Provider
From v0.21, Seamless Auth can act as an OpenID Provider. Any app that speaks OpenID Connect or OAuth 2.0 (Grafana, GitLab, Argo CD, Auth.js, Spring Security, a reverse proxy, your own backend) signs users in through hosted Seamless pages with passkeys and the other methods your instance allows, and gets standard ID tokens and access tokens back. The app needs no Seamless SDK.
Use this guide when an app should sign users in with standard OIDC instead of a Seamless adapter, when a backend or job needs tokens for an API (client credentials), or when your own API should accept tokens the provider issued. For apps you build yourself on a supported framework, the server adapters are still the shorter path. For native, desktop and CLI apps, read this page first and then Sign-In For Native, Desktop And CLI Apps.
For step-by-step setup, each run end to end against a local stack, see Connect Any OIDC App and the guides for Grafana, Auth.js, Spring Security and oauth2-proxy.
Architecture
Section titled “Architecture”The provider is a separate public service, the login edge, in front of the private API. The API stays bearer-only and cookie-free, and keeps every key and every piece of provider state.
any OIDC app ──► seamless-auth-oidc-provider (public: OIDC endpoints, hosted pages, cookies) │ its own service token (SEAMLESS_OIDC_SERVICE_TOKEN) ▼ seamless-auth-api (private, no cookies) ── Postgres- The edge is built on
oidc-provider9.x. It serves discovery,/authorize,/token,/userinfo,/jwks, introspection, revocation and the hosted sign-in and consent pages. It holds no private keys and no database. - The API stores the provider’s grants, codes and tokens in its own
oidc_payloadstable, reached through/oidc/*routes that accept only the edge’s service token. It signs every token with the provider key set (OIDC_JWKS_*) after checking that the user has a live session, that the claims match what the session earned, and that the client may use the audience and scopes. - Sign-in is first-party. The hosted pages sign users in through the edge’s own adapter routes
(
/auth/*), the same way any Seamless app does. A provider sign-in is an ordinary session in the API’ssessionstable, with every first-party protection applied, and ending that session ends provider access at once. - Provider tokens are not first-party tokens. They carry
issequal tooidc_issuer(never the API’sISSUER) and are signed with a separate key set, so the first-party verifiers in the Seamless adapters reject them, and the verify-only guards reject first-party tokens.
The design is recorded in ADR 0001.
Deploy The Edge
Section titled “Deploy The Edge”1. Pick the issuer hostname
Section titled “1. Pick the issuer hostname”The issuer is the edge’s public URL, for example https://login.example.com. The API refuses an
issuer that breaks any of these rules:
- It uses
https(plainhttpis allowed only on a loopback host outside production). - It has no query or fragment.
- It differs from the API’s own
ISSUER. - Its host is
rpidor a subdomain of it, because passkeys on the hosted pages are bound torpid.
Pick the hostname to fit the rpid you have. Changing rpid orphans every passkey already
registered.
2. Turn the provider on in the API
Section titled “2. Turn the provider on in the API”Set these on the API (see Environment Variables for the rest):
| Variable | Required | What |
|---|---|---|
OIDC_PROVIDER_ENABLED |
yes | Seeds oidc_provider_enabled. While off, every /oidc/* route answers 404 and the edge refuses to start. |
OIDC_ISSUER |
yes | Seeds oidc_issuer, the edge’s public URL. |
SEAMLESS_OIDC_SERVICE_TOKEN |
yes | The edge’s own service token secret. It must differ from SEAMLESS_SERVICE_TOKEN, or the API refuses to start. |
OIDC_JWKS_ACTIVE_KID |
yes | The kid of the provider key that signs. |
OIDC_JWKS_KEYS |
yes | A JSON JWK set of private RS256 keys, each with a unique kid. It must not reuse a SEAMLESS_JWKS_* key. |
OIDC_ALLOW_CLIENT_SECRETS |
no | Seeds allow_client_secrets. Lets an admin opt individual applications in to shared secrets. Off by default. |
OIDC_THIRD_PARTY_CLIENTS_ENABLED |
no | Seeds third_party_clients_enabled. Third-party applications need it, and always see a consent page. |
OIDC_HOSTED_UI |
no | Seeds hosted_ui, the hosted page branding, as JSON. See Brand the hosted pages. |
These env vars only seed system_config rows that do not exist yet. After the first boot, change
oidc_provider_enabled, oidc_issuer, allow_client_secrets, third_party_clients_enabled and
hosted_ui through the admin config routes, the dashboard or the CLI. With
oidc_provider_enabled on, the API refuses to start unless the issuer rules pass and the service
token and key set are present.
Also add the issuer origin (https://login.example.com) to the API’s origins config. WebAuthn
ceremonies on the hosted pages are checked against that list.
3. Run the edge
Section titled “3. Run the edge”docker run -p 5314:5314 --env-file edge.env ghcr.io/fells-code/seamless-auth-oidc-provider:v0.1.1Pin the edge to the version that matches your API. With auth API 0.21.x, use edge v0.1.1. Edge
v0.2.0, which is also the latest tag, speaks a newer /oidc/* contract and refuses to start
against auth API 0.21.1. The Compatibility Matrix lists
the pairs.
| Variable | Required | What |
|---|---|---|
SEAMLESS_API_URL |
yes | The private API, as the edge reaches it. |
SEAMLESS_API_ISSUER |
no | The API’s ISSUER, when it differs from SEAMLESS_API_URL. |
SEAMLESS_OIDC_SERVICE_TOKEN |
yes | The same value as on the API. At least 32 characters. |
SEAMLESS_JWKS_KID |
no | The kid on the service tokens the edge signs. |
SEAMLESS_COOKIE_SECRET |
yes | Signs the first-party session cookies the hosted pages use. At least 32 characters. |
OIDC_COOKIE_KEYS |
yes | Comma-separated provider cookie keys, each at least 32 characters. The first signs, the rest still verify. |
TRUST_PROXY |
no | true when TLS ends at a proxy in front of the edge. |
PORT |
no | Defaults to 5314. |
The issuer is not an edge setting. The edge reads it from the API at startup, along with the
provider key set and allow_client_secrets, and refuses to start when the provider is off or the
API speaks a different version of the /oidc/* contract. The image has a health check on
GET /healthz.
To embed the edge in an existing Express app instead of running the image, mount the router at the root of the app that serves the issuer’s origin:
import express from 'express'import { createOidcEdge } from '@seamless-auth/oidc-provider'
const edge = await createOidcEdge({ apiUrl: process.env.SEAMLESS_API_URL!, serviceSecret: process.env.SEAMLESS_OIDC_SERVICE_TOKEN!, cookieKeys: process.env.OIDC_COOKIE_KEYS!.split(','), adapterCookieSecret: process.env.SEAMLESS_COOKIE_SECRET!, trustProxy: true,})
const app = express()app.set('trust proxy', 1)app.use(edge.router)app.listen(5314)4. Serve it over HTTPS
Section titled “4. Serve it over HTTPS”Every cookie the edge sets is a __Host- cookie (host-only, Secure, Path=/), and the library
refuses to set a Secure cookie on a plain HTTP request, so the edge needs TLS in development too.
The edge itself listens on plain HTTP: terminate TLS at a proxy or load balancer and set
TRUST_PROXY=true so the forwarded scheme and host are trusted.
The edge answers only requests for the issuer’s own scheme and host. Anything else gets
421 {"error":"misdirected_request"}, so a proxy that rewrites the Host header, or a missing
TRUST_PROXY behind TLS termination, shows up as a 421.
5. Rotate provider keys
Section titled “5. Rotate provider keys”The edge reads the provider key set from the API when it starts, so restart it after each step:
- Add the new private JWK to
OIDC_JWKS_KEYSand deploy the API. The key is published at the edge’s/jwksand the old key still signs. Wait until relying parties have refreshed their cached JWKS. - Flip
OIDC_JWKS_ACTIVE_KIDto the new key and deploy. - Retire the old key once the longest provider token lifetime has passed.
Endpoints
Section titled “Endpoints”| Path | What |
|---|---|
/.well-known/openid-configuration |
Discovery |
/authorize |
Authorization endpoint (code flow with PKCE only) |
/token |
Token endpoint |
/userinfo |
UserInfo |
/jwks |
The provider’s public keys, read from the API |
/token/introspection |
Token introspection (RFC 7662) |
/token/revocation |
Token revocation (RFC 7009) |
/session/end |
RP-initiated logout, confirmed on a hosted page |
/device/auth |
Device authorization (RFC 8628) |
/device |
The hosted page where the user enters a device code |
/auth/*, /interaction/* |
Used by the hosted pages only, not by relying parties |
Point every relying party at the discovery document and let it read the rest.
Register Applications And APIs
Section titled “Register Applications And APIs”Applications (OIDC clients) and resources (your APIs) live in the API’s registry. They are managed
under /admin/applications and /admin/resources: reads need admin:read, and every write needs
admin:write plus a fresh step-up and is recorded as an admin_oidc_* auth event. The routes work
whether or not the provider is on, so you can register applications before the edge is deployed.
Applications
Section titled “Applications”An application has a client type and a set of grants:
| Setting | Values |
|---|---|
| Client type | confidential (a server that can keep a key) or public (SPA, native, CLI). Fixed at creation. |
| Client authentication | private_key_jwt by default for confidential clients, none for public ones. See Client authentication. |
| Grants | authorization_code, refresh_token, client_credentials. The default is authorization_code and refresh_token. |
| Redirect URIs | Exact https URIs, loopback http on 127.0.0.1 or [::1], or a private-use scheme such as com.example.app:/callback. Up to 50. |
| First-party | A first-party application skips the consent page. Third-party applications need third_party_clients_enabled. |
| Post-logout redirect URIs | Where sign-out may send the user back to. Same rules as redirect URIs. |
| Back-channel logout URI | An https URL that receives a logout token when the user’s session ends. See Back-channel logout. |
| Device flow | deviceFlowEnabled, first-party applications only. See Device authorization grant. |
| Token lifetimes | The application’s own access, ID and refresh token lifetimes. See Token lifetimes. |
| PKCE and nonce | pkceRequired and nonceRequired, both on by default. See PKCE and nonce opt-outs. |
| Account notices | Lets the application email or text its own users through Seamless. See Account notices. |
| Disabled | A disabled application cannot sign anyone in or get tokens, and keeps its settings. |
The client_id is generated when the application is created. The registry refuses settings that
would weaken the provider, on create and on the merged result of every update:
- Public clients authenticate with PKCE alone: no secret, no keys, no
client_credentials. client_secret_jwtcannot be chosen.localhost, fragments and embedded credentials are refused as redirect hosts, and plainhttpis allowed only on127.0.0.1and[::1].refresh_tokenneedsauthorization_codeor the device flow, andauthorization_codeneeds a redirect URI.- The device flow is for first-party applications only.
- Only a confidential client may turn PKCE off, and the nonce requirement can be turned off only once PKCE is.
The code flow as the only response type and refresh token rotation are fixed in the edge’s configuration and cannot be changed per application.
Resources
Section titled “Resources”A resource is an API your applications request access tokens for (RFC 8707). Its identifier is
an absolute https, http or urn URI, such as https://api.example.com. It becomes the aud of
every token for that API and cannot change after creation. It cannot be the API’s ISSUER or
oidc_issuer, and it is never fetched.
A resource declares its scopes (for example orders:read orders:write) and optionally an access
token lifetime between 60 and 86,400 seconds (600 by default). Each application is then linked to
the resources it may use, with a subset of each resource’s scopes. The OIDC scopes (openid,
profile, email, phone, address, offline_access) and seamless belong to the provider and
cannot be declared by a resource. Removing a scope from a resource removes it from every
application.
From the admin dashboard
Section titled “From the admin dashboard”The admin dashboard has an Applications page and a Resources page.
- Applications lists every application. Creating or editing one opens a form with the settings above, with each redirect URI checked as you type. Selecting an application shows its details, its credentials (register keys, or rotate a shared secret) and the resources and scopes it may use.
- Resources creates, edits and deletes resources and their scopes.
A shared secret is shown once, when the application is created or the secret is rotated. Only an HMAC of it is stored, so a lost secret is rotated, never recovered.
From the CLI
Section titled “From the CLI”seamless clients manages applications and seamless resources manages resources. Both need an
admin role, and their writes prompt for a step-up code when the instance asks for one. (seamless apps is a different command, for managed applications in the portal.)
# A confidential server-side app, authenticating with private_key_jwtseamless clients create Docs --redirect-uri https://docs.example.com/callback \ --jwks-uri https://docs.example.com/jwks.json
# A public desktop appseamless clients create Desk --public \ --redirect-uri http://127.0.0.1/callback --redirect-uri com.example.desk:/callback
seamless clients listseamless clients get <id>seamless clients update <id> --grants authorization_code,refresh_token --first-partyseamless clients keys <id> --jwks-file jwks.jsonseamless clients rotate-secret <id> # confirms, prints the secret onceseamless clients delete <id> # confirms first
# An API and its scopes, then link an application to itseamless resources create https://api.example.com --name "Orders API" \ --scopes orders:read,orders:write --access-token-ttl 300seamless resources listseamless resources update <id> --scopes orders:readseamless clients resources <id> --set https://api.example.com=orders:readcreate and update also take --name, --auth-method, --post-logout-uri,
--backchannel-logout-uri <url|none>, --first-party/--third-party,
--device-flow/--no-device-flow, --notices/--no-notices, and --access-token-ttl,
--id-token-ttl and --refresh-token-ttl (seconds, or default). update also takes
--disable/--enable. Most commands take --json. The CLI has no flag for the PKCE and nonce
settings: change those through the admin API.
From the API
Section titled “From the API”The same operations are plain admin routes on the API, for automation:
| Route | What |
|---|---|
GET/POST /admin/applications |
List or create applications |
GET/PATCH/DELETE /admin/applications/:id |
Read, change or delete one (deleting also drops its grants and tokens) |
POST /admin/applications/:id/secret |
Issue or rotate a shared secret |
PUT /admin/applications/:id/keys |
Register the JWKS or JWKS URI of a private_key_jwt client |
GET/PUT /admin/applications/:id/resources |
Read or replace the resources and scopes an application may use |
GET/POST /admin/resources |
List or create resources |
GET/PATCH/DELETE /admin/resources/:id |
Read, change or delete one |
Client Authentication
Section titled “Client Authentication”| Client | Authentication at /token |
|---|---|
| Confidential (server-side apps, services) | private_key_jwt, each assertion used once |
| Public (SPA, native, CLI) | none, with PKCE |
| Confidential, opted in to a shared secret | client_secret_basic or client_secret_post |
private_key_jwt is the default. Register the client’s public keys as a JWKS or a JWKS URI
(https), and have the client sign an assertion with the private key at the token, introspection and
revocation endpoints. RS256, PS256, ES256 and EdDSA keys are accepted. A replayed assertion is
refused as invalid_client. With openid-client:
import * as oidc from 'openid-client'
const config = await oidc.discovery( new URL('https://login.example.com'), 'CLIENT_ID', undefined, oidc.PrivateKeyJwt({ key: privateKey, kid: 'docs-2026' }),)Shared secrets are an exception for apps that support nothing else. They need two switches:
allow_client_secrets on the instance, which the edge reads when it starts (restart the edge after
changing it), and the shared-secret method on that application. The edge never holds a client’s
secret: it asks the API to check the presented one. client_secret_jwt and the HS* algorithms are
never offered.
Sign Users In: Code Flow With PKCE
Section titled “Sign Users In: Code Flow With PKCE”The authorization code flow with PKCE (S256) is the sign-in flow for every client, confidential or
public. The implicit and hybrid flows, plain PKCE and the password grant are refused. (A
confidential client can be opted out of PKCE, which is not
recommended.)
const verifier = oidc.randomPKCECodeVerifier()const url = oidc.buildAuthorizationUrl(config, { redirect_uri: 'https://docs.example.com/callback', scope: 'openid email', code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state: oidc.randomState(),})// Redirect the browser to `url`. On the callback:const tokens = await oidc.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: verifier, expectedState: state,})The user lands on the hosted sign-in page and signs in with any method enabled in login_methods.
A user already signed in to the edge goes straight back to the app. First-party applications have
consent implied. Third-party applications show a consent page with the application’s name, the host
it returns to and the requested scopes on every authorization.
Scopes and claims
Section titled “Scopes and claims”| Scope | Claims |
|---|---|
openid |
sub, acr, amr, auth_time |
email |
email, email_verified |
phone |
phone_number, phone_number_verified |
seamless |
roles, org_id (the user’s default organization) |
offline_access |
A refresh token. See Refresh tokens |
sub is the user’s Seamless user ID. Every ID token carries the acr, amr and auth_time the
API recorded for the session, whether or not the app asked for them. The claims are also in the ID
token itself, not only at /userinfo, because many apps read the profile from the ID token.
Without a resource, the access token is an opaque token for /userinfo. To call your own API,
ask for a token for it (see Access tokens for your API).
Token lifetimes
Section titled “Token lifetimes”| Token | Default | Per application (*TtlSeconds) |
|---|---|---|
| Authorization code | 60 seconds, single use | Fixed |
| ID token | 600 seconds | idTokenTtlSeconds, 60 to 86,400 |
| Access token | 600 seconds, or the resource’s lifetime | accessTokenTtlSeconds, 60 to 86,400 |
| Refresh token | 14 days | refreshTokenTtlSeconds, 300 to 7,776,000 (90 days) |
| Edge session | At most 14 days | Fixed |
Set an application’s lifetimes on create or update (PATCH /admin/applications/:id, or the
--access-token-ttl, --id-token-ttl and --refresh-token-ttl flags of seamless clients). Set
one to null (default in the CLI) to go back to the default.
- Access tokens for an API get the shorter of the resource’s lifetime and the application’s.
- The grant follows the refresh token. It lives at least as long as the application’s refresh tokens, so a refresh lifetime over 14 days is not cut short.
- Browser apps without a bound key (a public web client) keep what was left of the refresh token they rotated, whatever the application’s lifetime, so rotating never extends a stolen chain.
- The session still bounds them. When the user’s first-party session ends, the API ends every grant and token record under it, whatever its lifetime.
Authentication levels: acr and amr
Section titled “Authentication levels: acr and amr”acr |
Earned by |
|---|---|
phr |
A passkey sign-in with user verification |
phrh |
A device-bound passkey with user verification (not a synced passkey) |
| absent | Any sign-in without a user-verified passkey, such as an email code alone |
phrh satisfies a request for phr. amr lists the methods used, from hwk (device-bound
passkey), swk (synced passkey), user (user verification), otp, sms and mfa (two
different methods in one session, or a user-verified passkey).
PKCE and nonce opt-outs
Section titled “PKCE and nonce opt-outs”PKCE is required for every application by default. Two settings relax that, on confidential clients only:
| Setting | Default | Can be false when |
Effect when false |
|---|---|---|---|
pkceRequired |
true |
The client is confidential (it authenticates at /token) |
A request with openid and no code_challenge is accepted if it carries a nonce |
nonceRequired |
true |
pkceRequired is already false |
That request is accepted without a nonce too |
A request without openid still needs PKCE, because a nonce only binds an ID token. A public
client always needs PKCE, whatever its settings. The registry checks both rules on create and on the
merged result of every update, so turning PKCE back on while the nonce is off is refused. Set them
with PATCH /admin/applications/:id. Like every application write, the change needs admin:write
and step-up, and is recorded as admin_oidc_application_pkce_updated or
admin_oidc_application_nonce_updated.
Step-Up: acr_values, max_age And prompt=login
Section titled “Step-Up: acr_values, max_age And prompt=login”Discovery lists acr_values_supported: ["phr", "phrh"]. An app asks for a stronger or fresher
sign-in on the authorization request:
| Parameter | Met when |
|---|---|
acr_values=phr |
The session’s acr is phr or phrh. Several values mean the weakest one listed is enough. |
acr_values=phrh |
The session’s acr is phrh |
max_age=300 |
The session’s auth_time is at most 300 seconds old, or the user authenticated just now |
prompt=login |
The user authenticated during this authorization request |
The edge decides from the session’s auth context as the API reports it on every authorization, so a
step-up done in another app counts. The claims request parameter is not enabled, so acr_values is
the only way to ask for an acr.
When a requirement is not met, the hosted page asks the user to confirm on the same session: with a
passkey, or with an authenticator app code when only freshness is missing on a session that has no
acr (an app code never earns an acr). The new acr, amr and auth_time go into the tokens
unchanged. If the step-up still falls short, for example a synced passkey when phrh was asked
for, the app gets the error unmet_authentication_requirements instead of a token with a lower
acr.
const url = oidc.buildAuthorizationUrl(config, { redirect_uri: 'https://docs.example.com/callback', scope: 'openid', acr_values: 'phr', max_age: '300', code_challenge, code_challenge_method: 'S256',})The acr, amr and auth_time an app can rely on are the ones in the ID token. Access tokens for
an API do not carry them yet (see Verify tokens in your API).
Access Tokens For Your API
Section titled “Access Tokens For Your API”An app that calls an API of its own asks for a token for it with resource (RFC 8707), using the
identifier of a resource the application is linked to:
const url = oidc.buildAuthorizationUrl(config, { redirect_uri: 'https://docs.example.com/callback', scope: 'openid orders:read', resource: 'https://api.example.com', code_challenge, code_challenge_method: 'S256',})The access token is then an RFC 9068 JWT (header typ: at+jwt, RS256) with:
audequal to the resource identifier, exactly as registered.scopecut to the scopes the application is allowed on that resource.client_id,sub,iss,iat,expandjti.- The resource’s own lifetime.
The API checks the same links again before it signs, so a token for one resource can never carry another resource’s audience or scopes.
- A resource the application is not linked to is refused with
invalid_target. - An app that named one resource at authorization gets its token without repeating
resourceat the token endpoint, which many RP libraries never do. - A token for a resource is not accepted at
/userinfo. - First-party applications get the resource scopes with the sign-in. Third-party applications list them on the consent page.
Refresh Tokens
Section titled “Refresh Tokens”An application registered for the refresh_token grant gets a refresh token when it asks for
offline_access. The library honours offline_access only on a request with prompt=consent
(OpenID Connect Core section 11), so send both:
const url = oidc.buildAuthorizationUrl(config, { redirect_uri: 'https://docs.example.com/callback', scope: 'openid offline_access', prompt: 'consent', code_challenge, code_challenge_method: 'S256',})// Later:const refreshed = await oidc.refreshTokenGrant(config, tokens.refresh_token!)- Rotation is always on. Every refresh returns a new refresh token and the old one stops working.
- Reuse ends the session. A used refresh token presented again revokes the grant and every token under it, and the API ends the first-party session it was issued under, so every app on that session has to sign in again.
- Concurrent refreshes count as reuse. Two refreshes sent at the same time with the same token
cannot both rotate. The one that loses is handled as reuse, so both get
invalid_grantand the session ends. Refresh from one place at a time. - Refreshed ID tokens are current. They carry the session’s current
acr,amrandauth_time, so a step-up since the sign-in shows up on the next refresh. - The session bounds it. A refresh token works only while the user’s first-party session is live, so ending that session in Seamless ends it.
Client Credentials
Section titled “Client Credentials”A confidential application registered for client_credentials can get tokens for itself, with no
user, for a backend job or a service-to-service call. The token is always for a resource: the one
named with resource, or the application’s only linked resource. An application linked to several
resources must name one.
const machine = await oidc.discovery( new URL('https://login.example.com'), 'MACHINE_CLIENT_ID', undefined, oidc.PrivateKeyJwt({ key: privateKey, kid: 'jobs-2026' }),)const { access_token } = await oidc.clientCredentialsGrant(machine, { resource: 'https://api.example.com', scope: 'orders:read',})The token is an RFC 9068 JWT whose sub and client_id are both the application. It has no user
claims, so it can pass a scope check but never a role or step-up check. Public clients cannot use
this grant.
Introspection And Revocation
Section titled “Introspection And Revocation”/token/introspection (RFC 7662) and /token/revocation (RFC 7009) are listed in discovery and take
the same client authentication as /token. Both are limited to 1,200 requests a minute per client
IP, per edge instance.
Introspection. An application may introspect its own tokens. A resource server registers as a
confidential application linked to its resource, and may then introspect tokens whose aud is that
resource. Any other caller gets { "active": false }. Every answer is read from the API’s store, so
a token whose first-party session has ended is inactive at once.
const rs = await oidc.discovery( new URL('https://login.example.com'), 'ORDERS_API_CLIENT_ID', undefined, oidc.PrivateKeyJwt({ key: rsPrivateKey, kid: 'orders-api-2026' }),)const status = await oidc.tokenIntrospection(rs, accessToken)if (!status.active) throw new Error('token is no longer valid')JWT access tokens can be introspected and revoked too: the edge records each one under its jti
when it is issued. A resource server that verifies JWTs locally still accepts a revoked token until
it expires. One that introspects sees the revocation immediately. Keep resource token lifetimes
short when that gap matters.
Revocation. An application revokes its own refresh or access token:
await oidc.tokenRevocation(config, tokens.refresh_token!)Sign-Out
Section titled “Sign-Out”RP-initiated logout
Section titled “RP-initiated logout”/session/end (OpenID RP-Initiated Logout 1.0) is listed in discovery as end_session_endpoint.
Send the browser there with the ID token as id_token_hint (or the app’s client_id), and
optionally a post_logout_redirect_uri registered for the application and a state:
const url = oidc.buildEndSessionUrl(config, { id_token_hint: tokens.id_token!, post_logout_redirect_uri: 'https://docs.example.com/signed-out', state: oidc.randomState(),})// Clear the app's own session, then redirect the browser to `url`.A post_logout_redirect_uri that is not registered for the application is refused. The hosted
page names the app and asks before anything ends:
- Sign out ends the user’s Seamless session: the edge session and the first-party session it was made under. Every other app the user signed in to with that session is signed out too, and apps with a back-channel logout URI are told (below).
- Only sign out of the app ends that app’s grant and keeps the session, so the user stays signed in to their other apps.
The browser then goes to the post_logout_redirect_uri, with state, or to a hosted signed-out
page. Each sign-out is recorded as an end_session auth event.
Back-channel logout
Section titled “Back-channel logout”An application with a back-channel logout URI is told when the user’s session ends (OpenID
Back-Channel Logout 1.0). Discovery lists backchannel_logout_supported and
backchannel_logout_session_supported, and ID tokens for such an application always carry sid.
The API sends the logout tokens itself, not the edge, because it sees every way a session ends:
| How the session ended | When the token is sent |
|---|---|
| Sign out on the hosted page | At once |
Sign-out in a first-party app (/logout, /logout/all) |
A sweep starts at once |
| Any other way, such as an admin revoking the session | On the next sweep, at most 30 seconds later |
The token is a logout+jwt signed with the provider key set (verify it against the edge’s
/jwks). It carries iss, aud (the client_id), sub, sid, jti, iat, exp (2 minutes)
and the back-channel logout event. It is POSTed as logout_token in a form body, and a 200 or
204 within 5 seconds counts as delivered. Each application is told once per session. The outcome
is recorded as oidc_backchannel_logout_sent or oidc_backchannel_logout_failed.
On receipt, verify the token, then end every local session for that sid (or for sub if you do
not track sid).
Device Authorization Grant
Section titled “Device Authorization Grant”The device authorization grant (RFC 8628) is for apps that cannot open a browser: TVs, consoles, and CLIs on a server or in a container. The app shows a short code, and the user approves it on another device with a browser.
Register a device application
Section titled “Register a device application”The device flow is available only to first-party applications with deviceFlowEnabled. A device
app is usually a public client and needs no redirect URI:
seamless clients create "Living Room TV" --public --first-party --device-flow \ --grants refresh_tokenAn application without the device flow is refused at /device/auth.
The flow
Section titled “The flow”- The app POSTs to
/device/authwith itsclient_id(and client authentication if it is confidential) and ascope. The answer carriesdevice_code,user_code,verification_uri(the edge’s/devicepage),verification_uri_complete,expires_inandinterval: 5. - The app shows the user the code and the verification URI (or a QR code of
verification_uri_complete). - The app polls
/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeand thedevice_code, waitingintervalseconds between polls. - The user opens the page, enters the code and approves. The next poll returns the tokens.
With openid-client, which handles the polling and slow_down for you:
const device = await oidc.initiateDeviceAuthorization(config, { scope: 'openid email offline_access',})console.log(`Go to ${device.verification_uri} and enter ${device.user_code}`)const tokens = await oidc.pollDeviceAuthorizationGrant(config, device)An application registered for refresh_token gets a refresh token when it asks for
offline_access. It rotates like every other refresh token.
| Poll answer | Meaning |
|---|---|
authorization_pending |
The user has not approved yet. Keep polling. |
slow_down |
The app polled sooner than interval. Add 5 seconds and keep going. |
access_denied |
The user cancelled on the confirmation page. |
invalid_grant |
The device code was already used, or has expired. |
The verification page
Section titled “The verification page”- Codes. The user code is 8 characters from the RFC’s base-20 set (no vowels), shown as
BCDF-GHJK. It is typed case-insensitively, with or without the dash. Codes live 10 minutes and each device code works once. Code entry is limited to 10 tries a minute per address and 600 a minute per edge instance. - Confirmation. Before any sign-in, the page names the app, shows the code and the scopes, and tells the user to continue only for a device they started themselves (RFC 8628 section 5.4).
- Sign-in every time. Every approval signs in again, so a browser already signed in to Seamless
still confirms with the passkey (or another enabled method). Approvals and cancellations are
recorded as
device_code_approvedanddevice_code_denied.
No acr on device tokens
Section titled “No acr on device tokens”Device-flow tokens never carry acr, after a refresh too. The passkey proves who approved the code,
not who holds the device, so a device token cannot satisfy a phr or phrh check. A device request
with acr_values or max_age is refused with invalid_request, and the API refuses to sign acr
onto any token from the device grant. amr still lists the methods the approver used.
Brand The Hosted Pages
Section titled “Brand The Hosted Pages”The sign-in, step-up and consent pages are served by the edge, built on @seamless-auth/react.
Their branding comes from the API’s hosted_ui config, read each time a page is served (the edge
caches the config for up to 30 seconds), so a change needs no build and no restart.
| Field | Rules |
|---|---|
appName |
1 to 100 characters. Defaults to the instance’s app_name. |
logoUrl |
An https URL where the image is already hosted, on a plain hostname. The instance does not store uploads. |
colors |
primary, onPrimary, background, surface, text, muted, each #rgb, #rrggbb or #rrggbbaa. |
fontFamily |
A comma-separated list of installed font names. Web fonts cannot load under the pages’ CSP. |
Setting any color turns off the pages’ dark palette. The API does not check contrast, so check your colors against WCAG AA yourself (the dashboard warns when a pair falls short).
-
Dashboard: the System page, section Hosted Page Branding, with a preview. Saves and resets apply immediately.
-
CLI:
Terminal window seamless config hosted-ui getseamless config hosted-ui set --app-name Acme --logo https://cdn.example.com/acme.svgseamless config hosted-ui set --primary '#0b5fff' --on-primary '#fff'seamless config hosted-ui reset colors.primary logoUrl -
API:
PATCH /system-config/adminwith{ "hosted_ui": { ... } }(the whole object is replaced, not merged) or{ "hosted_ui": null }. It needsadmin:writeand a fresh step-up. -
Env:
OIDC_HOSTED_UIseeds it on first boot.
The pages send a strict CSP (script-src 'self', frame-ancestors 'none'), no-store, nosniff
and no-referrer. The logo’s origin is added to img-src.
Account Notices From An Application
Section titled “Account Notices From An Application”An application can tell one of its users something (an export is ready, a payment failed) on the user’s verified email or phone, without ever holding the address. The API sends the text inside a fixed template that names the application and the instance, so a notice cannot pose as a Seamless security message.
- Turn on account notices for the application (
--noticesin the CLI, or the checkbox in the dashboard) and register it forclient_credentials. - Get a client credentials token for the built-in resource
<oidc_issuer>/seamlesswith scopenotices:send. The API lists that resource for every application with notices on, so you do not create it. - Call
POST /applications/account-noticeson the API with that token as a bearer token.
const { access_token } = await oidc.clientCredentialsGrant(machine, { resource: 'https://login.example.com/seamless', scope: 'notices:send',})
await fetch('https://auth-api.internal.example.com/applications/account-notices', { method: 'POST', headers: { authorization: `Bearer ${access_token}`, 'content-type': 'application/json' }, body: JSON.stringify({ userId: '6f1c...', // the user's `sub` subject: 'Your export is ready', text: 'The export you asked for is ready to download.', actionUrl: 'https://docs.example.com/exports/42', }),})| Field | Rules |
|---|---|
userId |
The user’s sub. The user must hold a grant with this application. |
subject |
One line, 1 to 120 characters. |
text |
Plain text, 1 to 2,000 characters. |
actionUrl |
Optional. https, on the origin of one of the application’s registered redirect URIs. |
channels |
Optional, email and/or sms. Every verified channel when omitted. |
The response lists channels and their status, never addresses. Errors are notice_invalid_token
(401), notice_not_enabled (403), notice_unknown_user (404, which also covers a user with no grant
for the application, so an app cannot learn who else has an account), notice_action_url_not_allowed
(400), notice_no_channel (422) and notice_rate_limited (429). The limits are 300 notices an hour
per application and 10 an hour per application and user.
A backend that serves a public client, such as a desktop app, cannot reach that app’s users this way, because users hold grants with the app and not with the backend. Client links solve that, and they need Seamless Auth API v0.23.0, which is not published yet. See Send A Notice Through The Client Link.
Verify Tokens In Your API
Section titled “Verify Tokens In Your API”An API that receives the provider’s access tokens as Authorization: Bearer needs only the
issuer and its own resource identifier. The verify-only guards in every Seamless adapter check
those tokens the same way, held to shared signed test vectors, with no cookie secret, service secret
or auth routes:
- RS256 only, header
typat+jwt, akidfrom the issuer’s key set, and nocritheader. issequal to the issuer exactly, the audience inaud,exprequired, 5 seconds of clock skew.subandclient_idpresent. A first-party Seamless session token never passes.- A sender-constrained token (
cnf) is refused unless you pass a confirmation hook. - Keys come from
<issuer>/.well-known/openid-configuration(itsissuermust match, and itsjwks_urimust be https when the issuer is), or from ajwks_uriyou set. If either cannot be fetched the request gets401 invalid_token. Nothing fails open.
| Refusal | Status | Body error |
WWW-Authenticate |
|---|---|---|---|
| No bearer token | 401 | missing_token |
Bearer |
| Token fails verification | 401 | invalid_token |
Bearer error="invalid_token" |
| Missing scope (all needed) | 403 | insufficient_scope, plus scope |
Bearer error="insufficient_scope", scope="..." |
| Missing role (any one) | 403 | insufficient_role |
none |
| Step-up not met | 401 | insufficient_user_authentication |
RFC 9470 challenge with error_description, acr_values, max_age |
The step-up guard takes acr_values (any of, and phr is also met by phrh), amr (all of) and
max_age (seconds since auth_time). A client credentials token verifies, and fails role and
step-up guards.
Express, Fastify and Next.js (@seamless-auth/express, @seamless-auth/fastify,
@seamless-auth/nextjs):
import { createResourceServer } from '@seamless-auth/express'
const api = createResourceServer({ issuer: 'https://login.example.com', audience: 'https://api.example.com',})
app.get('/orders', api.requireAuth(), api.requireScope('orders:read'), listOrders)app.post( '/payouts', api.requireAuth(), api.requireStepUp({ acrValues: ['phr'], maxAge: 300 }), createPayout,)Next.js uses api.protect(handler, { scopes, roles, stepUp }) on a route handler.
Go (github.com/fells-code/seamless-auth-go):
rs, err := seamlessauth.NewVerifier(seamlessauth.VerifierOptions{ Issuer: "https://login.example.com", Audience: "https://api.example.com",})if err != nil { log.Fatal(err)}mux.Handle("GET /orders", rs.RequireToken(rs.RequireScope("orders:read")(http.HandlerFunc(list))))Python (seamless_auth, FastAPI and Django):
from fastapi import Depends, FastAPIfrom seamless_auth import ResourceServer, VerifiedUser, require_scopefrom seamless_auth.fastapi import RequireBearer, add_bearer_error_handler
rs = ResourceServer(issuer="https://login.example.com", audience="https://api.example.com")app = FastAPI()add_bearer_error_handler(app)
@app.get("/orders")def orders(user: VerifiedUser = Depends(RequireBearer(rs, require_scope("orders:read")))): return {"sub": user.sub}Rust (seamless_auth, axum):
let verifier = Verifier::builder("https://login.example.com") .audience("https://api.example.com") .build()?;
let app: Router = Router::new().route( "/orders", get(list_orders) .route_layer(verifier.require_scope(["orders:read"])?) .route_layer(verifier.require_auth()),);Each adapter’s README lists its full verify-only API. See the Go, Python and Rust adapter pages for installation.
Security Defaults
Section titled “Security Defaults”The edge’s configuration is locked to OAuth 2.1 and RFC 9700. The only application setting that loosens it is the PKCE and nonce opt-out for confidential clients:
- The code flow with
S256PKCE for every client, unless a confidential client is opted out. No implicit, hybrid or password grant. - Exact redirect URI matching. The only exception is the port of a native app’s loopback redirect.
isson every authorization response (RFC 9207).- Refresh token rotation, with reuse ending the session.
- Audience-restricted access tokens for APIs.
private_key_jwtby default, shared secrets only by opt-in, neverclient_secret_jwt.- No dynamic client registration: applications come only from the registry.
- Errors go only to a registered redirect URI. An unregistered one gets an error page.
- Hosted pages cannot be framed, and their state-changing routes need a same-origin request.
The full matrix, with the test behind each row, is in the edge repo’s docs/oauth-security.md.