Skip to content
Authreads Docs

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.

API sign-in v2 — password, proof, setup, exchange

Authenticate every call from your backend with the app login credential./api/v1/auth/login/preflight serves only v2; the optional Authreads-Login-Flow header accepts only 2. After password verification, follow next_method and alternatives: email code, authenticator, recovery code or a passkey on a verified application domain. Replace your stored flow_token after every transition. The original ten-minute expiry never extends; Back cancels the flow. Keep secrets and capabilities out of browser JavaScript, URLs and logs.

Backend-owned authenticator journey
POST /api/v1/auth/login/preflight
Authreads-Login-Flow: 2
{"email":"user@example.com","password":"…","min_acr":"urn:authreads:acr:mfa"}

POST /api/v1/auth/login/flow/verify-totp
{"flow_token":"<latest capability>","code":"<authenticator code>"}

# Only after a response containing grant:
POST /api/v1/auth/session/exchange
{"grant":"<one-time grant>"}

An unfinished response omits grant. Handle password_change_requiredwith /api/v1/auth/password/change-expired and {flow_token,new_password}, then restart sign-in. For strong_method.state Ask or Required, use/login/flow/enrol/totp/start and /confirm, or the passkey enrollment begin/finish endpoints. Only unexpired Ask permits /login/flow/remind-later. Enrollment authority is the flow; no account session is issued before setup. Setup uses the same account-wide strongest-factor rule as account security, including passkeys on another RP. Select an existing authenticator or recovery code before setup when that passkey cannot be used here. Preflight prefers an available recovery code before sending an email that cannot authorize setup; always follow the returned method. A final consumed recovery code remains repair proof; it does not become MFA evidence.

For passkeys, send the exact registered Authreads-Passkey-Origin on preflight, then call /login/flow/passkey/begin with the latest flow and origin. Forward the returned challenge options to the browser or native client and return the signed credential with its ceremony ID to /finish. Android certificate origins and Apple associated domains require verified app-domain registration; an arbitrary display domain is insufficient.

flow_superseded (409) requires a new flow; retries never issue another grant. Reverify returns 401 session_invalid for unknown, other-tenant, revoked or expired sessions. Clear the application session and require sign-in. A live same-tenant session for another app returns 403 session_app_mismatch. Respect Retry-After on 429. login_proof_rejected andlogin_method_unavailable need a server-directed proof or method change.strong_method_setup_required blocks completion until setup.fresh_authentication_required requires a fresh strongest-method sign-in; additional methods belong in authenticated account security.stronger_proof_unavailable_here means no usable repair proof is available: manage methods where the existing passkey works or use the organisation’s audited reset process.recovery_proof_required (403 on setup) means only a recovery code can authorize setup on this site: restart sign-in and choose recovery_code, or manage methods where the passkey works. A response carrying recovery_codes_exhausted: true (a sign-in with the last recovery code, or a setup it authorised) left the account with no unspent recovery codes; prompt the user to generate new ones. A malformed remembered-browser value returns 400trusted_device_invalid: discard the stored value and retry without it.app_passkey_domain_unverified and app_login_unavailable need an administrator repair. Retired endpoints return endpoint_removed with HTTP 410.

Completed v2 grants and valid end-user session responses expose acr,auth_time and cumulative amr. Contexts are orderedsingle, pwd, 2fa, mfa,phr, each prefixed with urn:authreads:acr:. Email and recovery with password reach 2fa; authenticator with password reaches mfa; a user-verified passkey reaches phr. Trusted-browser skips and enrollment bootstrap stay pwd. The proof supporting the strongest context owns auth_time; later password checks cannot make an older strong proof appear fresh. Operator tokens omit this end-user context. These values do not assert NIST AAL certification.

Request an optional min_acr on v2 preflight to restrict the proof methods. An existing password-proven app session can instead POST/api/v1/auth/login/flow/reverify with{"session_id":"<session bearer>","min_acr":"urn:authreads:acr:mfa"}. Follow the same rotating proof flow. Completion returns session_reverified:trueand fresh context without a grant; retain the original session and expiry. Passkeys require the exact verified origin at admission and ceremony calls.unmet_authentication_requirements means no configured method meets the floor; direct the user to account security setup. Trusted-device skips cannot satisfy it.

remember_device_unavailable is a 400 refusal before proof consumption; clear the option and retry with remember_device=false. Hosted OAuth publishes the same value in error_code. API and hosted offers, acceptance and token creation share one rule; reverify, Required rollout, recovery and requests above password strength cannot mint browser trust. Refresh the offer after method changes and resend.

On an actionable verification step, trusted_device_available tells you whether to offer remember-device; trusted_device_window_days uses effective policy. These fields are omitted during setup, password change, reverify and terminal responses. Test amr membership, never equality. First strong-factor enrolment returns recovery_codes once. Follow Let users manage 2FA from your app for authenticated account changes and lost-response recovery.

For a shared browser, keep a bounded, newest-first device_tokens list in backend-owned storage or an HttpOnly cookie. Send it on preflight and again with a completed verification. After completion, replace the stored list with the returneddevice_tokens. Keep the old material through a code challenge so a verified re-trust can rotate the same browser record. The platform permits at most ten entries, with a default storage cap of five. Never send both input forms; the singulardevice_token remains supported for existing clients. Keep all device credentials out of browser JSON and logs. Each entry is checked only for the user proving their password, tenant, pool, browser fingerprint and current strength requirement.

Tenant administrators can review the latest invitation, sign-in code and confirmation in Admin’s Email delivery section; platform operators have the equivalent Console view. Status is limited to the configured retention window and current address generation. Delivered means the receiving mail server accepted the message, not that it was read. These are private management views, not additional public sign-in endpoints. A current-address permanent bounce or complaint blocks invitation resends withinvite_delivery_blocked. A committed provisioning result instead carriesinvite.reason: delivery_blocked; preserve the successful account change and show its delivery warning. Correct the address or investigate the complaint before retrying.

Required is checked again before grant issuance, including after policy changes and remembered browser admission. Enrollment preserves the original AMR; a password-only setup does not become MFA. Recovery cannot create browser trust. Preflight always serves v2; explicit unsupported versions are refused. Retired v1 verification endpoints return 410.

The login sequence

A first-party login is four calls that use two credentials. The first three — preflight, verify, exchange — run from your backend 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.

  1. Preflight — POST /api/v1/auth/login/preflight
    Send { email, password, min_acr? }. Follow next_method, alternatives and any setup/password-change requirement. Replace the flow token after every transition.
  2. Verify the offered method
    Send the rotating flow_token and user proof to the matching /api/v1/auth/login/flow/* endpoint. A completed response contains grant. Offer remember-device only when trusted_device_available is true; the policy window is trusted_device_window_days.
  3. Exchange — POST /api/v1/auth/session/exchange
    Trade the single-use grant for 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_id is the bearer used by authenticated calls; account_session_id is a non-credential handle used to retain the current session during self-service revocation; session_policy and session_activity_token are the idle-timeout contract described below.
  4. Mint an access token — POST /api/v1/auth/token
    From your product backend, authenticated with your API Access machine token (tokens:issue scope), 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.
acr, amr & auth_time. The token records how the user authenticated — amr records pwd (password), otp (code), or pop (verified passkey proof), and auth_time is the time of the proof supporting acr. Read these when a sensitive action needs a fresh or stronger login (see step-up below). A passkey whose user verification is cryptographically verified emits pop and mfa; password plus passkey emits pwd, pop, and mfa. Password plus authenticator app emits pwd, otp, and mfa. An email code alone does not earn mfa. These claims do not promise a device-bound key or an assurance level.

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:

ClaimMeaning
subThe user's Authreads id — stable across environments.
emailThe user's email.
tenant_id / tenantYour tenant's public id and name.
appThe target application this token was minted for.
app_grantsApplications this user may reach (role ∩ org-type ∩ package).
module_grants / permissionsFiner-grained grants your product enforces inside an app.
roleThe user's role key in the resolved organization.
org_type / workspace_idThe organization's type, and the resolved workspace.
environmentThe workspace environment (e.g. production, staging).
amrVerified methods: pwd (password), otp (code), pop (passkey); mfa for a UV-verified passkey or password plus authenticator app.
acrThe strongest authentication context actually proved by this end-user session.
auth_timeTime of the proof supporting that authentication context (Unix seconds).
iss / audIssuer, and the audience — your registered app key.
iat / expIssued-at and expiry (Unix seconds).
Verify 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

From your backend, use the app login credential to POST /api/v1/auth/login/flow/reverify with {"session_id":"<session bearer>","min_acr":"urn:authreads:acr:mfa"}. Complete the offered proof flow. The final response contains session_reverified:true, current assurance and the original expiry; no grant or replacement session is issued.

Test amr membership, not equality. Use acr and auth_time for strength and freshness. A fresh weak proof cannot freshen an older strong proof. See self-service factors for required headers, strongest-proof rules and one-time recovery codes.

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:

POST /api/v1/auth/session/validate — sessions:validate scope
{ "session_id": "5b1e2c40-…" }
# → { "valid": true, "user": { "id": "…", "email": "…" }, "expires_at": "2026-08-11T18:42:00Z" }
# → { "valid": false, "reason": "revoked" }  # or "expired" / "user_disabled" / …
POST /api/v1/auth/session/invalidate — sessions:invalidate scope
{ "session_id": "5b1e2c40-…" }
# → { "message": "session invalidated" }
These act on the same session created by 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.
  1. Send it on every activity-checked call
    Alongside x-session-id, send the current token as x-session-activity. GET /api/v1/auth/me validates it on every call; a missing, tampered, cross-session, wrong-tenant, idle, or absolute-expired token fails 401 with a problem+json code of session_activity_invalid, session_idle_timeout, or session_activity_expired.
  2. Refresh it on genuine user activity — POST /api/v1/auth/session/activity
    Call 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-id and the current x-session-activity; store the token this returns in place of the old one:
    POST /api/v1/auth/session/activity
    curl -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" }
    This call has its own rate limit; an over-limit refresh returns 429 with Retry-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.

Account-security operations
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/policy

Reads 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.

Tenant-user device trust is created after that user completes a one-time-code challenge; it is not an operator-only feature. The service persists tenant-user and platform-operator devices through separate subject paths and applies separate lifetimes: your resolved tenant policy for users (up to the platform ceiling), and a platform-owned fixed window for operators. Signing out ends sessions but does not silently remove a trusted device; use the explicit device-revocation operations above when that is the intended action.

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.

IP provenance fields
// 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 null as “inactive”, and do not treat it as a reason to hide a session: every session in this response is live.
Mutation audits name the API client as the actor and retain the represented user as the on-behalf-of subject. Repeating a completed revocation returns an honest zero and creates no duplicate audit event.