Skip to content
authreads Docs

Sign in with authreads

Standard OpenID Connect against the authreads-hosted login page. Use this when you want a drop-in “Sign in with authreads” button instead of building your own login UI — any conformant OIDC client library works, with the caveats below.

Check where this flow answers in your environment. Environments & base URLs states, per environment, exactly where this flow currently answers — and gives you the one request that tells you yourself rather than taking our word for it.
Which path do I want? Use OIDC when authreads hosts the login screen. If your product renders its own login form, use the first-party flow in Sessions & tokens instead. Both authenticate against the same tenant user pool.
Local OTP delivery is explicit. A local development binary can write authentication email to a dedicated stderr credential channel only when it is built with the dev-email feature and configured with AUTH_ENV=dev plus AUTH_EMAIL_TRANSPORT=console. OTPs and password links never enter the operational tracing/file sink. This transport is not present in production artifacts and never replaces the production email sender after a delivery failure.
This is a deliberately narrow OIDC provider, not a generic one. A handful of parameters that most libraries send by default — prompt, max_age, and anything else /oauth/authorizedoesn't explicitly recognize — are rejected outright rather than ignored. Read Known incompatibilities with common OIDC libraries before you wire up an off-the-shelf client; it will save you a debugging session.

Discovery

Point your OIDC client at the discovery document — it lists every endpoint, supported scope, and the signing algorithm, so you rarely hard-code URLs. auth.example.com below is a placeholder: authreads is a single shared API host per environment (tenants are distinguished by client_id, not by subdomain). The real host for your environment — the value that becomes your issuer — is published in Environments & base URLs. The issuerin the document always equals the origin you fetched it from, as OpenID Connect Discovery 1.0 §4.3 requires, so a conformant library needs no override. Resolve every other endpoint from the document rather than constructing URLs by hand, so a future path change doesn't break your integration:

GET /.well-known/openid-configuration
curl https://auth.example.com/.well-known/openid-configuration
# → {
#   "issuer": "https://auth.example.com",
#   "authorization_endpoint": ".../oauth/authorize",
#   "token_endpoint":         ".../oauth/token",
#   "userinfo_endpoint":      ".../oauth/userinfo",
#   "revocation_endpoint":    ".../oauth/revoke",
#   "end_session_endpoint":   ".../oauth/logout",
#   "backchannel_logout_supported": true,
#   "backchannel_logout_session_supported": true,
#   "jwks_uri":               ".../.well-known/jwks.json",
#   "id_token_signing_alg_values_supported": ["EdDSA"],
#   "code_challenge_methods_supported": ["S256"],
#   "token_endpoint_auth_methods_supported": ["none","client_secret_basic",
#                                              "client_secret_post"],
#   "grant_types_supported": ["authorization_code","refresh_token",
#                             "client_credentials",
#                             "urn:ietf:params:oauth:grant-type:token-exchange"],
#   "scopes_supported": ["openid","profile","email","memberships",
#                        "offline_access","account:sessions"]
# }

Register an OIDC application

OIDC clients are registered by hand in the admin dashboard — there is no dynamic/self-service registration endpoint. One active app can hold at most one active OIDC client; revoke and re-register if you need to start over.

  1. Open the credential form
    In the admin dashboard, go to API and credentials → Create credential → Sign in with Authreads, then pick the application this client belongs to. Any active app is eligible — unlike a login-password credential, the app does not need This app signs users in enabled.
  2. Choose the client type — this is permanent
    Confidential (server application) or Public (browser or native application, PKCE only, no secret). This cannot be changed after creation; register a new client if you got it wrong.
  3. Add redirect and logout URIs
    One exact URI per line. Every URI, in both fields, must be:
    • An absolute HTTPS URL, except that native authorization and post-logout callbacks may use plain HTTP on localhost, 127.0.0.1, or [::1].
    • Free of a userinfo/password component and free of a #fragment.
    • Free of wildcards — matching is exact-string, not prefix or pattern. For the three native loopback hosts only, the callback port may differ on both authorization and logout so the OS can assign a free port.
    • At most 2048 bytes, and not duplicated within the same list (20 URIs max per list).
    Redirect URIs and post-logout redirect URIs can be edited later; the client type cannot. Add a public HTTPS backchannel_logout_uri if Authreads must notify your server when a session ends. Private, loopback, link-local, credential-bearing, wildcard, and fragment URLs are rejected; DNS is checked again on every delivery. To change them on an existing client, open API and credentials in the admin console, find the Sign in with Authreads row for your application, and choose Manage OIDC client.
  4. Pick scopes
    openid is required and always on. Choose from profile, email, memberships, offline_access, and account:sessions— any scope outside this set is rejected. Scopes can be widened or narrowed later; a client can only ever request what it's been granted here.
  5. Save the client secret now — it is shown exactly once
    Confidential clients get a client_id and a client_secret, shown once in a reveal dialog; authreads stores only a digest and cannot show it again. Public clients get only a client_id— no secret is ever issued. Rotating a confidential client's secret invalidates the previous one immediately, with no overlap window.
Authorization and post-logout use one redirect policy. Register each loopback shape, for example http://127.0.0.1:3000/callback and http://127.0.0.1:3000/signed-out, bind the native listener to port 0, and send the OS-assigned port at runtime. Authreads ignores only that loopback port; scheme, host, path, and query still have to match. Every non-loopback URI requires HTTPS and an exact match, including its port.

Authorization code + PKCE

  1. Send the user to /oauth/authorize
    Redirect the browser with your client_id, a registered redirect_uri, response_type=code, the scopes you need, a state, a nonce, and a PKCE code_challenge (S256). All of these are required — unlike most OIDC providers, nonceis not optional here, and any parameter the endpoint doesn't recognize (including prompt and max_age) fails the request instead of being ignored:
    GET /oauth/authorize
      ?client_id=<client_id>
      &redirect_uri=https://app.acme.com/callback
      &response_type=code
      &scope=openid%20profile%20email%20memberships
      &state=<opaque>
      &nonce=<opaque>
      &code_challenge=<base64url-sha256>
      &code_challenge_method=S256
  2. User authenticates on the hosted page
    authreads renders the login (and OTP, if required), then redirects back to your redirect_uri with ?code=…&state=…. Verify state matches what you sent.
  3. Exchange the code at /oauth/token
    Swap the code for tokens, sending the same PKCE code_verifier. How you authenticate depends on the client type you registered — mixing the two patterns is rejected:
    POST /oauth/token — public client (no secret)
    curl -X POST https://auth.example.com/oauth/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=authorization_code" \
      -d "code=<code>" \
      -d "redirect_uri=https://app.acme.com/callback" \
      -d "client_id=<client_id>" \
      -d "code_verifier=<pkce-verifier>"
    # → { "access_token":"…", "id_token":"…", "refresh_token":"…",
    #     "token_type":"Bearer", "expires_in":900, "scope":"openid profile …" }
    POST /oauth/token — confidential client (HTTP Basic)
    curl -X POST https://auth.example.com/oauth/token \
      -u "<client_id>:<client_secret>" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=authorization_code" \
      -d "code=<code>" \
      -d "redirect_uri=https://app.acme.com/callback" \
      -d "code_verifier=<pkce-verifier>"
    # → { "access_token":"…", "id_token":"…", "refresh_token":"…",
    #     "token_type":"Bearer", "expires_in":900, "scope":"openid profile …" }
    Confidential clients: never send client_id in the form body here — a confidential client authenticates with an Authorization: Basic header only (client_secret_basic; curl's -u flag builds it for you). client_secret_post is also supported for libraries that send the client ID and secret in the form. Sending a secret in the form and a Basic header together is rejected with invalid_client. Public clients do the opposite: no Authorization header, and no client_secret — they authenticate with PKCE alone.
  4. Call userinfo (optional)
    GET /oauth/userinfo with the access token returns the standard OIDC claims for the signed-in subject. Prefer reading the id_token directly when you can.
Request offline_access to receive a refresh_token, then use grant_type=refresh_token at the token endpoint to get a new access token without sending the user back through the browser. Refresh, like the code exchange, authenticates confidential clients with the same HTTP Basic header — not a body secret.
Validate the nonce. The value you sent on /authorize is echoed back as the nonce claim inside the returned id_token. Compare it to what you generated and stored for this login attempt (keyed by state, alongside the PKCE verifier) before trusting the token — this is what stops a captured or replayed id_token from being accepted into a different login.
PKCE failing with invalid_grant? The code_challenge must be the base64url SHA-256 of the verifier (method S256) — not the raw verifier, and not standard base64. The verifier must be 43–128 characters, stored keyed by stateso parallel logins don't overwrite each other, and it must survive the redirect (don't keep it only in memory). Prefer a vetted client library over hand-rolling the flow.

Verified email claim

Request the email scope to receive both email and the standard email_verified boolean in the ID token. Without that scope, authreads omits both claims entirely; it never emits a guessed value.

Authreads records first-hand verification only after a credential it delivered to the mailbox is redeemed: a login or step-up code, invite/onboarding link, or password-reset link. A raw set-password link returned to a tenant does not count as mailbox proof. The same OIDC login that redeems its emailed code already returns email_verified: true.
A tenant may explicitly assert prior verification through the Management API. The boolean in the ID token remains the interoperable OIDC claim; provenance is available only from Management API user reads as authreads_otp, authreads_token, or tenant_asserted.

Three tokens, three jobs

The token response can contain three tokens — using the wrong one is the most common OIDC bug:

TokenWhat it's forWhere it goes
id_tokenProof of who the user is, for your app/session.Your app only — never send it to an API.
access_tokenThe ticket to call an API.Sent as Bearer to APIs; your backend verifies it.
refresh_tokenGet new access tokens without re-login.Backend only — keep it secret.
Rule of thumb: if you're calling an API, use the access token — and verify it the same way you verify a first-party token (EdDSA / JWKS).
Your application owns its post-login OIDC session, so it must enforce human inactivity locally. After verifying the ID token, read idle_timeout_minutes from its signed https://authreads.com/claims/session_policy claim. This claim is emitted on both authorization-code and refresh-token ID tokens; do not infer activity from a token refresh. Direct/BFF integrations instead use the enforced x-session-activity contract.

Scopes

ScopeGrants
openidRequired for OIDC — returns an id_token.
profileBasic profile claims.
emailThe user's email claim.
membershipsThe user's org memberships and roles.
offline_accessIssues a refresh_token for long-lived access.
account:sessionsSelf-service session management (see below).

Logout & token revocation

“I logged out but the token still works” comes from treating logout as one action when it's actually three independent layers:

  • End the hosted session — redirect to GET /oauth/logout (the end_session_endpoint). The next /authorize will prompt for login again.
  • Revoke the token POST /oauth/revoke invalidates a refresh (or access) token immediately, cutting off renewal.
  • The access token's TTL — an already-issued access token is self-contained and stays valid until it expires, even after logout. That's why access-token lifetimes are short: you rely on the short TTL plus refresh-token revocation, not on “instant” access-token death.

If you send logout state, Authreads echoes it unchanged onto the already-validated post-logout redirect. Use a random correlation value to verify the response belongs to your logout request. It is not a secret channel: do not put tokens, personal data, or application state inside it.

Log out everywhere = end the session + revoke the user's other sessions (POST /oauth/account/sessions/revoke-others, below) so no device keeps renewing. Note that step-up re-verification refreshes a session — it does not end one.

Back-channel logout

Implement OpenID Connect Back-Channel Logout 1.0 at the HTTPS URI registered on your client. Authreads sends a form-encoded logout_tokenoutside the browser when an OIDC session is revoked or reaches absolute expiry. Delivery is asynchronous: temporary failures are retried at 1, 5, 30, and 120 minutes, then shown as a persistent failure in the client settings. The user's own logout never waits for your endpoint.

Back-channel request — sent to your registered backchannel_logout_uri
POST https://app.acme.com/your-backchannel-logout-uri
Content-Type: application/x-www-form-urlencoded

logout_token=<signed logout+jwt>
Local development uses a public HTTPS tunnel. Run your receiver locally, expose that exact route through a temporary HTTPS tunnel, and register the tunnel URL as backchannel_logout_uri. Authreads intentionally rejectslocalhost, private/link-local IPs, redirects, and non-HTTPS endpoints in every environment because this server-to-server callback is an SSRF boundary. Keep tunnel access logs free of the form body: logout_token is a short-lived signed credential.
  • Verify the EdDSA signature and kid with the discovery JWKS.
  • Require your issuer and exact client ID in iss/aud, and validate iat/exp.
  • Require the standard back-channel logout events member and reject any token containing nonce.
  • Deduplicate on jti, then terminate the session identified by sid. Receiving an already-ended session is success.
  • Return 200 or 204; do not redirect.

Self-service session management

With the account:sessionsscope a signed-in user can see and revoke their own sessions — useful for a “devices & sessions” screen in your product:

  • GET /oauth/account/sessions — list the user's active sessions.
  • POST /oauth/account/sessions/{session_id}/revoke — sign out one session.
  • POST /oauth/account/sessions/revoke-others — sign out everywhere else.
The hosted pages also expose password recovery under /oauth/account/password/* (forgot, reset, resend, and a status check) so users can recover access without leaving the authreads login experience.
Need to force re-authentication instead (what prompt=login or max_age would do on a generic OIDC provider)? /oauth/authorize rejects both — use the session re-verification API described in Sessions & tokens instead, or see Known incompatibilities with common OIDC libraries.