Sessions & tokens
How a login turns into a session, and a session into a signed access token your product can verify offline. Every step here uses your own server — the reader never sees an authreads screen.
The login sequence
A first-party login is four calls that use two credentials. The first three — preflight, verify, exchange — run from your login page or service with your app login credential (X-Tenant-Id + alcs_… bearer). The fourth — minting the token — runs from your product backend with its API Access machine token (see Management API). Nothing here uses the hosted login page — that's a separate path documented in Sign in with authreads.
- Preflight — POST /api/v1/auth/login/preflightSend
{ email, password }. The response is{ otp_required, grant }. Ifotp_requiredisfalse, you already hold a one-timegrant; iftrue, an email OTP has been sent andgrantisnulluntil you verify it. - Verify OTP — POST /api/v1/auth/login/verify-otpOnly when
otp_requiredwas true. Send{ email, code }(addremember_device: trueto reduce future OTP prompts). The response returns the one-timegrant. - Exchange — POST /api/v1/auth/session/exchangeTrade the single-use
grantfor a durable session:{ user, session_id, account_session_id, session_policy, session_activity_token, kind }. Store all of these in your server-side session.session_idis the bearer used by authenticated calls;account_session_idis a non-credential handle used to retain the current session during self-service revocation;session_policyandsession_activity_tokenare the idle-timeout contract described below. - Mint an access token — POST /api/v1/auth/tokenFrom your product backend, authenticated with your API Access machine token (
tokens:issuescope), send{ session_id, app, workspace_id? }to mint a short-lived, EdDSA-signed access JWT scoped to that user and target app. Verify it on every request; re-mint it when it expires rather than lengthening its lifetime.
amr is a list containing pwd and/or otp, and auth_time is when they last proved it. Read these when a sensitive action needs a fresh or stronger login (see step-up below).What's inside the access token
The access token is a compact JWT your product verifies against the JWKS (no call back to authreads). Its payload — the AccessClaims — carries both identity and the authorization your product enforces:
| Claim | Meaning |
|---|---|
| sub | The user's authreads id — stable across environments. |
| The user's email. | |
| tenant_id / tenant | Your tenant's public id and name. |
| app | The target application this token was minted for. |
| app_grants | Applications this user may reach (role ∩ org-type ∩ package). |
| module_grants / permissions | Finer-grained grants your product enforces inside an app. |
| role | The user's role key in the resolved organization. |
| org_type / workspace_id | The organization's type, and the resolved workspace. |
| environment | The workspace environment (e.g. production, staging). |
| amr | How the user authenticated: pwd and/or otp. |
| auth_time | When the user last authenticated (Unix seconds). |
| iss / aud | Issuer, and the audience — your registered app key. |
| iat / exp | Issued-at and expiry (Unix seconds). |
iss, aud, and the EdDSA signature against /.well-known/jwks.json — the same checks covered in Authentication settings. Keys are Ed25519 (EdDSA); match the token's kid so key rotation is seamless.Step-up re-verification
Before a sensitive action — changing a password, approving a payment — ask the user to prove themselves again without starting a new session. From your product backend (API Access machine token, sessions:reverify scope), call POST /api/v1/auth/session/reverify. It accepts a password, or an email-OTP request followed by a verify:
# Password re-check
{ "kind": "password", "session_id": "5b1e…", "password": "••••••••" }
# → { "status": "verified", "authenticated_at": "2026-07-28T10:12:00Z", "amr": ["pwd"] }
# Or email OTP: request, then verify
{ "kind": "email_otp_request", "session_id": "5b1e…" }
# → { "status": "challenge_sent" }
{ "kind": "email_otp_verify", "session_id": "5b1e…", "code": "123456" }
# → { "status": "verified", "authenticated_at": "…", "amr": ["otp"] }authenticated_at and amr. Re-mint the access token afterwards so the fresh auth_time/amrflow through to your product's checks. Re-verification has its own rate-limit brake — see Errors & rate limits.Validate or force-end a raw session
Two narrower internal-plane calls operate directly on a session_id, for a backend that tracks its own direct sessions rather than going through the account-security endpoints above. From your product backend, with an API Access machine token:
{ "session_id": "5b1e2c40-…" }
# → { "valid": true, "user": { "id": "…", "email": "…" }, "expires_at": "2026-08-11T18:42:00Z" }
# → { "valid": false, "reason": "revoked" } # or "expired" / "user_disabled" / …{ "session_id": "5b1e2c40-…" }
# → { "message": "session invalidated" }POST /session/exchange above — not the browser-facing account-security handles. sessions:validate is read-only and not marked dangerous; sessions:invalidate ends the session and is. See the full scope list in Management API → Scopes.Session activity & the idle-timeout contract
Every session carries a resolved idle timeout — how long it tolerates real human inactivity before authreads treats it as ended. session_policy.idle_timeout_minutes from the exchange step above is that resolved value; session_activity_token is a short, API-signed, session-bound token that carries it forward. Store it alongside session_id.
GET /me requires x-session-activity — it is not optional. Send the stored activity token on every call, or the request fails closed with 401 before your session lookup even runs.- Send it on every activity-checked callAlongside
x-session-id, send the current token asx-session-activity.GET /api/v1/auth/mevalidates it on every call; a missing, tampered, cross-session, wrong-tenant, idle, or absolute-expired token fails401with a problem+jsoncodeofsession_activity_invalid,session_idle_timeout, orsession_activity_expired. - Refresh it on genuine user activity — POST /api/v1/auth/session/activityCall this only when your frontend observes real pointer or keyboard input from the signed-in user, never on a timer, a background poll, or a token refresh — none of those count as activity, and refreshing on them defeats the idle timeout entirely. Send
x-session-idand the currentx-session-activity; store the token this returns in place of the old one:This call has its own rate limit; an over-limit refresh returnsPOST /api/v1/auth/session/activitycurl -X POST https://auth.example.com/api/v1/auth/session/activity \ -H "x-session-id: <session_id>" \ -H "x-session-activity: <current activity token>" # → { "activity_token": "<new token>", "expires_at": "2026-08-11T18:42:00Z" }429withRetry-After, same as the other brakes in Errors & rate limits.
Read the current user — /me
GET /api/v1/auth/me resolves a session to the user's identity and full memberships (org id, name, role key). Call it from your product backend with the API Access machine token (identity:read scope), passing the session in an x-session-id header and the current activity token in x-session-activity (see above). It is explicit about multi-org users:
- Exactly one membership → permissions and accessible apps are resolved automatically.
- Multiple memberships and no
?organization_id=…→ identity + memberships only, with no permission block (never guessed). - Pass
?organization_id=<uuid>to resolve permissions and apps for a specific organization.
Self-service sessions and trusted devices
If you host your own login pages, your backend can give users the same account-security controls as the hosted Authreads UI. Obtain a client-credentials token and call the routes below with an explicit user id. The credential supplies the tenant boundary; caller input can never select a tenant.
GET /api/v1/auth/users/{user_id}/account-security/sessions
POST /api/v1/auth/users/{user_id}/account-security/sessions/{session_id}/revoke
POST /api/v1/auth/users/{user_id}/account-security/sessions/revoke-others
GET /api/v1/auth/users/{user_id}/account-security/devices
POST /api/v1/auth/users/{user_id}/account-security/devices/{device_id}/revoke
POST /api/v1/auth/users/{user_id}/account-security/revoke-others-and-devices
GET /api/v1/auth/users/{user_id}/account-security/policyReads and the renderable policy require account-security:read. Revocations requireaccount-security:manage. For “revoke others” and the combined action, send { "current_session_id": "<account_session_id>" }. Session inventory is tenant/user-pool scoped, not app-scoped — one sign-in reaches every app in the user's pool, so the list includes sessions started through other apps too. Each returned session still names the app it was originally created through, for display. Trusted devices are tenant/user-pool scoped for the same reason: the same trusted browser can authenticate the user across apps in that pool.
IP fields are display locations, not enforcement keys. Hosted/OIDC flows can return anobserved_client_ip because the browser reaches Authreads directly. Confidential BFF flows may return null with not_recorded when Authreads only saw your server. If your backend sends Authreads-End-User-IP, that claim is kept in audit provenance but is not echoed back as a session location.
// Session item
{ "ip": "203.0.113.7", "ip_source": "observed_client_ip" }
{ "ip": null, "ip_source": "not_recorded" }
// Trusted-device item
{
"first_seen_ip": "203.0.113.7",
"first_seen_ip_source": "observed_client_ip",
"last_used_ip": null,
"last_used_ip_source": "not_recorded"
}The session list reflects both session clocks. A session is returned only if it is inside its absolute lifetime and has not passed the idle timeout, so a browser abandoned hours ago stops being listed without the user having to sign out. The trusted-device active_session_count uses the same rule, so the count always matches the number of sessions actually listed.
Each session carries last_activity_at, so you can render “last active 4 minutes ago” beside it.
last_activity_at is null when a session has never reported activity — which means its idle state is unknown, not that it is inactive. Sessions used purely through POST /api/v1/auth/session/validate never report activity, and the idle timeout is not applied to them either. Do not render nullas “inactive”, and do not treat it as a reason to hide a session: every session in this response is live.