Skip to content

Developer documentation

API reference

The HTTP API of the Logintoo Authorization Server — an OAuth 2.0 authorization server (RFC 6749) with PKCE (RFC 7636) that signs users in with one-time email codes — or, for organizations, through their own OpenID Connect identity provider — and issues RS256-signed JSON Web Tokens.

On this page

Overview

Logintoo is self-hosted, so the base URL is your own deployment: the API is served at api.<your-domain>, and the first path segment is the apiVersion you deploy (currently 2026-06). All paths on this page are relative to that base.

https://api.example.com/2026-06
  • Every endpoint speaks JSON: send Content-Type: application/json request bodies and expect JSON back.
  • Clients are public clients (RFC 6749 §2.1) — there are no client secrets. Every login is protected by PKCE with code_challenge_method=S256.
  • CORS is open (Access-Control-Allow-Origin: *) and every resource answers OPTIONS preflight, so the API is callable directly from the browser.
  • Responses are never cached (Cache-Control: no-store), with one exception: the JWKS endpoint is public key material and is cacheable.
  • /auth, /otp and /idp/* are front-channel endpoints, normally called by the hosted login page — not by your application. Your application redirects the user to the login page and later exchanges the authorization code at /token.
  • Users can also sign in through their organization’s own OIDC identity provider — see Single sign-on. A federated login ends in the same authorization code, so /token, refresh and logout are identical either way.

Login flow

  1. Your app generates a random PKCE code_verifier and state, computes code_challenge = base64url(SHA-256(code_verifier)), and redirects the user to the login page of your deployment with the OAuth parameters in the query string:
    https://login.example.com/
      ?client_id=01ARZ3NDEKTSV4RRFFQ69G5FAV
      &redirect_uri=https%3A%2F%2Fapp.example.com%2F
      &response_type=code
      &state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M
      &code_challenge=E9MpoLslmYPwLSqx7hmdOSFvhh8mkFcHDCLxdIsG320
      &code_challenge_method=S256
    Optional language and locale parameters select the language of the one-time-code email and are carried through to the token claims; an optional theme (light or dark) pins the login page’s appearance instead of following the visitor’s system setting.
  2. The login page asks for the user’s email address and calls POST /auth; the server emails a one-time code.
  3. The user enters the code; the login page calls POST /otp and follows the returned redirect. The browser lands back on your redirect_uri with code, state, and iss query parameters.
  4. Your app checks that state matches the value it sent and that iss is your issuer (RFC 9207 mix-up defence), then exchanges the code at POST /token using the original code_verifier. It receives an access token and a rotating refresh token.
  5. While the session lasts, PATCH /token refreshes the pair; DELETE /token logs out.
  6. Your API (the resource server) verifies access tokens offline against the public keys from /.well-known/jwks.json.

Steps 2–3 are the one-time-code path. When the address belongs to an organization that has federated its email domain, the login page sends the user to that organization’s identity provider instead, and the browser returns to the same redirect_uri with the same three parameters — every step before and after is unchanged. See Single sign-on.

A complete working client — PKCE generation, redirect handling, token exchange, refresh, and verification — is the open-source Sample App (live demo).

POST/auth

Starts a login: validates the authorization request, stores it, and emails the user a one-time code. Called by the hosted login page with the OAuth parameters it received from your app plus the email address the user typed.

Request body

ParameterTypeDescription
client_id requiredstring · ULIDThe application’s ID, as registered on the server.
code_challenge requiredstring · 43–128Base64url-encoded SHA-256 hash of the code_verifier (characters A–Z a–z 0–9 - _).
code_challenge_method requiredstringMust be S256.
email requiredstring · ≤ 254The address the one-time code is emailed to.
redirect_uri requiredstring · URLWhere the user returns after login. Must be an https:// URL and exactly match one of the client’s registered redirect URIs.
response_type requiredstringMust be code (authorization code flow).
state requiredstring · 43–128Opaque value from your app, echoed back on the redirect. Verify it matches to prevent CSRF.
languagestring · 2 lettersPreferred language of the one-time-code email (e.g. en). Carried through to the redirect and token claims.
localestring · xx-XXRegional locale (e.g. en-CA). Carried through like language.
cf_turnstile_responsestringCloudflare Turnstile token. Required — and verified at the edge — only when the deployment has Turnstile bot protection enabled.

Example request

curl -X POST https://api.example.com/2026-06/auth \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
    "code_challenge": "E9MpoLslmYPwLSqx7hmdOSFvhh8mkFcHDCLxdIsG320",
    "code_challenge_method": "S256",
    "email": "user@example.com",
    "redirect_uri": "https://app.example.com/",
    "response_type": "code",
    "state": "hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M"
  }'

Responses

200 OK The request was accepted. The code is generated and emailed asynchronously, so 200 means accepted, not delivered.

{
  "statusCode": 200,
  "statusMessage": "200 OK"
}

If the client_id is unknown, the redirect_uri is not on the client’s allow-list, the application has turned the one-time code off entirely (otpEnabled: false), or the address belongs to an organization that requires SSO, the request is dropped after acceptance and no email is sent — deliberately, so the endpoint can’t be used to probe registered clients or federated domains.

400 Bad Request invalid_request — a parameter is missing or malformed. Also returned when Turnstile verification fails on a Turnstile-enabled deployment.

POST/otp

Exchanges the emailed one-time code for an authorization code. Called by the hosted login page. On success the response carries the redirect target: your registered redirect_uri with code, state, and iss query parameters appended (plus language/locale when they were sent to /auth).

Request body

ParameterTypeDescription
client_id requiredstring · ULIDSame value as sent to /auth.
code_challenge requiredstring · 43–128Same value as sent to /auth — identifies the pending login together with email.
email requiredstring · ≤ 254The address the code was sent to.
otp requiredstring · 6–8 digitsThe one-time code from the email.

Responses

302 Found The default: a redirect back to your application.

Location: https://app.example.com/
  ?code=tzWXCJbHVMOZH0LWLQbSy2DPnrGr0eL1kSMxOprnbXY
  &state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M
  &iss=https%3A%2F%2Flogintoo.com

200 OK When the request carries Accept: application/json, the same target is returned in the body instead — a browser fetch() cannot follow a cross-origin redirect, so the login page navigates itself.

{
  "location": "https://app.example.com/?code=tzWXCJbHVMOZH0LWLQbSy2DPnrGr0eL1kSMxOprnbXY&state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M&iss=https%3A%2F%2Flogintoo.com"
}

The authorization code is single-use and expires after 120 seconds.

401 Unauthorized invalid_client — the code is wrong, expired, or out of attempts, or no login is pending for this address. The response is deliberately identical in all of these cases, and each wrong entry burns one attempt (default: 4).

403 Forbidden access_denied — the redirect_uri bound to this login is no longer registered for the client.

400 Bad Request invalid_request — a parameter is missing or malformed.

Single sign-on (SSO)

A user can also sign in through their organization’s own OpenID Connect identity provider — Google Workspace, Microsoft Entra ID, Okta, or any standard OIDC provider — instead of a one-time email code, so a corporate administrator grants and revokes access from their own directory.

Federation is configured per organization: a record that owns a set of verified email domains and one or more identity providers. A client opts in by listing the organization in its orgs registration field. A deployment with no organizations configured behaves exactly as before — the endpoints below simply report that there is nothing to federate with.

A federated login ends in the same single-use authorization code the one-time-code flow produces, so POST /token, refresh and logout are identical either way, and PKCE still binds the exchange. sub is still the normalized email address, so a user keeps one identity whichever way they signed in; the idp, amr and org claims tell your application which path produced the token.

Routing

The login page calls POST /idp/discover with the address the user typed, and the reply decides the route:

ReplySituationWhat the user gets
method: otpThe domain is not claimed by an organization this client federates with — or that organization has no enabled provider.The one-time-code flow, unchanged.
method: otp
otpFallback: true
The organization is in a staged rollout.Both: the code form, with the provider offered alongside it.
method: idpThe organization requires SSO on its domains — the default — or the application is SSO-only.A redirect to the identity provider. The one-time code is refused for this address on this client.
method: noneNo method is left: the application is SSO-only and this address has no provider behind it.Nowhere to go — the login page says so, rather than showing a code form whose code would never arrive.

That refusal is enforced, not merely advised: POST /auth still answers 200 for an address whose organization requires SSO, but no code is generated and no email is sent — the same silent drop an unregistered redirect_uri gets, so the endpoint cannot be used to enumerate federated domains.

An application can also switch the one-time code off wholesale, with otpEnabled: false on its own client record, making it SSO-only for every address — federated or not. That is a client-level decision and it wins: an organization’s otpFallback: true cannot re-enable a method the application has turned off, so a staged rollout on such a client collapses to the mandatory-SSO row above.

Identity boundaries

  • An identity provider may only assert an identity whose email domain is one of its own organization’s verified domains; anything else is rejected. This is the invariant that keeps one customer’s directory from minting identities in another’s namespace — and it is why a guest account invited into a corporate tenant (a personal address, say) cannot sign in through that tenant.
  • Domains are claimed exactly, with no wildcards: owning acme.com confers no authority over contractors.acme.com.
  • An assertion whose email the provider has not marked verified is rejected, unless that provider is registered as an exception (some providers never emit the claim).
  • An organization can cap its users’ session length independently of the client’s own token settings — see Limits.

POST/idp/discover

Resolves an email address to a routing decision: one-time code, identity provider, or both. Called by the hosted login page as the user types and again on submit. It sends no email and creates no login, and reveals only domain policy — Logintoo holds no user accounts, so there is nothing else to disclose.

Request body

ParameterTypeDescription
client_id requiredstring · ULIDThe application’s ID.
emailstring · ≤ 254The address the user typed. Omit it to ask instead for every provider this client can offer — button mode, for a login page that shows SSO buttons before asking for an address.

Example request

curl -X POST https://api.example.com/2026-06/idp/discover \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
    "email": "someone@acme.com"
  }'

Responses

200 OK The address belongs to an organization that requires SSO:

{
  "method": "idp",
  "org_name": "ACME Corp",
  "idp": {
    "org_id": "01J8ZKACMEHQ4T9V2XKPB3NZDR",
    "id": "acme-entra",
    "displayName": "Sign in with ACME"
  },
  "idps": [
    {
      "org_id": "01J8ZKACMEHQ4T9V2XKPB3NZDR",
      "id": "acme-entra",
      "displayName": "Sign in with ACME"
    }
  ]
}

200 OK Nothing is federated for this address — carry on with the one-time code:

{
  "method": "otp"
}

200 OK The application is SSO-only and this address has no provider behind it — there is no way in:

{
  "method": "none"
}
ParameterTypeDescription
methodstringotp — use the one-time-code flow. idp — use SSO. none — no method is available: the application is SSO-only and this address has no provider (or, in button mode, the application has no providers at all).
otpFallbackbooleanPresent and true when the organization is in a staged rollout: method is otp, but the providers listed are valid too — offer both. Never present for an application that has set otpEnabled: false.
org_namestringDisplay name of the organization the address belongs to, for copy such as “You’ll be redirected to ACME Corp to sign in.”
idpobjectThe first entry of idps, for the common single-provider case.
idpsobject[]Each entry carries org_id and id — round-trip both to /idp/start — plus displayName and an optional https:// logoSrc. Nothing about the provider’s configuration is exposed.

Discovery fails open. If an organization has claimed the domain but has no enabled provider yet, the reply is {"method": "otp"} — a configuration gap does not lock its users out. On an application that has disabled the one-time code there is nothing to fall back to, so the same case answers {"method": "none"}.

400 Bad Request invalid_request — a parameter is missing or malformed.

403 Forbidden access_denied — the client is not registered.

POST/idp/start

Begins a federated login: validates the request against the client, the organization and the identity provider, stores it, and returns the provider’s authorization URL for the browser to navigate to. The SSO counterpart of /auth — the same OAuth / PKCE body minus email, plus the org_id and idp_id that /idp/discover returned.

Request body

ParameterTypeDescription
client_id requiredstring · ULIDThe application’s ID, as registered on the server.
code_challenge requiredstring · 43–128Base64url-encoded SHA-256 hash of the code_verifier.
code_challenge_method requiredstringMust be S256.
redirect_uri requiredstring · URLWhere the user returns after login. Must be an https:// URL and exactly match one of the client’s registered redirect URIs.
response_type requiredstringMust be code.
state requiredstring · 43–128Opaque value from your app, echoed back on the final redirect.
org_id requiredstring · ULIDThe organization, from /idp/discover.
idp_id requiredstring · ≤ 64The identity provider within that organization, from /idp/discover.
languagestring · 2 lettersCarried through to the redirect and the token claims. Nothing is emailed on this path, so it selects no email language.
localestring · xx-XXCarried through like language.

Example request

curl -X POST https://api.example.com/2026-06/idp/start \
  -H 'Content-Type: application/json' \
  -d '{
    "client_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
    "code_challenge": "E9MpoLslmYPwLSqx7hmdOSFvhh8mkFcHDCLxdIsG320",
    "code_challenge_method": "S256",
    "redirect_uri": "https://app.example.com/",
    "response_type": "code",
    "state": "hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M",
    "org_id": "01J8ZKACMEHQ4T9V2XKPB3NZDR",
    "idp_id": "acme-entra"
  }'

Responses

200 OK Navigate the top-level window to location. As with /otp, the target is returned in the body rather than as a 302, because a browser fetch() cannot follow a cross-origin redirect.

{
  "location": "https://login.microsoftonline.com/9a1c…/oauth2/v2.0/authorize?response_type=code&client_id=…&redirect_uri=https%3A%2F%2Fapi.example.com%2Fidp%2Fcallback&scope=openid+email+profile&state=…&nonce=…&code_challenge=…&code_challenge_method=S256"
}

There is no email field — the identity provider establishes who the user is. The state and code_challenge in that URL are Logintoo’s own, single-use, for the provider leg; your application’s state is held server-side and echoed back on the final redirect exactly as in the one-time-code flow. PKCE is applied twice: once between your application and Logintoo, once between Logintoo and the provider.

400 Bad Request invalid_request — a parameter is missing or malformed.

403 Forbidden access_denied — the redirect_uri is not registered for the client, the provider is unknown or disabled, or the organization is unknown, disabled, or not one this client federates with.

GET/idp/callback

Where the identity provider returns the user. This is a top-level browser navigation, not a fetch() — neither your application nor the login page ever calls it. It exchanges the provider’s code, verifies the ID token, enforces the organization’s domain boundary, and mints the same single-use authorization code the one-time-code flow produces.

Registered once in the customer’s Entra / Google / Okta application as https://api.<your-domain>/idp/callbackwithout the version segment every other path on this page carries. That URL is deliberately fixed, so bumping your apiVersion never asks a customer’s administrator to re-register anything.

Query parameters

ParameterTypeDescription
state requiredstring · 43–128Logintoo’s own value for this provider leg — not your application’s state. Single-use: it is consumed the first time it resolves.
codestringThe provider’s authorization code. Present on success.
error, error_descriptionstringSent instead of code when the provider refused the login — the user cancelled, or an administrator policy blocked them.

Responses

302 Found Success — the same redirect /otp produces, so your application handles it with the code it already has:

Location: https://app.example.com/
  ?code=tzWXCJbHVMOZH0LWLQbSy2DPnrGr0eL1kSMxOprnbXY
  &state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M
  &iss=https%3A%2F%2Flogintoo.com

302 Found Failure, once the redirect_uri has been confirmed against the client — reported the OAuth way (RFC 6749 §4.1.2.1) rather than as an HTTP status:

Location: https://app.example.com/
  ?error=access_denied
  &error_description=Your+account+is+not+part+of+this+organization%27s+verified+domains.
  &state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M
  &iss=https%3A%2F%2Flogintoo.com
ErrorCause
access_deniedThe provider refused the login; or it asserted an email it has not verified; or the asserted identity’s domain is not one of the organization’s verified domains.
temporarily_unavailableThe organization or the provider was disabled while the login was in flight.
invalid_requestThe provider returned neither a code nor an error.
server_errorUnexpected failure, including a failure while talking to the provider.

400 Bad Request invalid_request — the state is missing, malformed, expired, or already used. This is the one federation failure returned as JSON: with no login to resolve there is nowhere safe to redirect to.

Handle both code and error on your redirect URI. Your application already needs this for the one-time-code flow; federation simply makes it reachable more often.

POST/token

Exchanges the authorization code plus the PKCE code_verifier for an access token and a refresh token. This is the back-channel call your application makes after the user lands on the redirect_uri.

Request body

ParameterTypeDescription
grant_type requiredstringMust be authorization_code.
code requiredstring · 43–128The authorization code from the redirect.
redirect_uri requiredstring · URLThe exact redirect_uri used in the authorization request.
client_id requiredstring · ULIDThe application’s ID.
code_verifier requiredstring · 43–128The plain PKCE verifier whose SHA-256 hash was sent as code_challenge.

Example request

curl -X POST https://api.example.com/2026-06/token \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "tzWXCJbHVMOZH0LWLQbSy2DPnrGr0eL1kSMxOprnbXY",
    "redirect_uri": "https://app.example.com/",
    "client_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
    "code_verifier": "wr8ZTB2Mgstc0GQoU5UkE0hbxIHNXLC7T4iAB0zoxHs"
  }'

Responses

200 OK

{
  "statusMessage": "200 OK",
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiZDA4YjE0LTh…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiZDA4YjE0LTh…",
  "exp": 1782003600,
  "rt_exp": 1782604800,
  "state": "hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M"
}
ParameterTypeDescription
access_tokenstring · JWTRS256-signed access token — see Access-token claims.
token_typestringAlways Bearer.
expires_innumberAccess-token lifetime in seconds (per-client setting, default 3600).
refresh_tokenstring · JWTRotating refresh token — present it to PATCH /token to get a new pair.
expnumberAccess-token expiry as a Unix timestamp (seconds).
rt_expnumberRefresh-token expiry as a Unix timestamp (seconds).
statestringThe state of this login, echoed once more.
language, localestringEchoed when they were supplied to /auth.

400 Bad Request invalid_grant — the code is expired, already redeemed, bound to a different redirect_uri/client_id, or PKCE verification failed. invalid_request — malformed body.

403 Forbidden access_denied — the client is not registered.

PATCH/token

Refreshes the session: verifies the refresh token, issues a new access + refresh token pair, and rotates the refresh token — the one you presented stops working immediately. Always store the newest pair.

Request body

ParameterTypeDescription
grant_type requiredstringMust be refresh_token.
refresh_token requiredstring · JWTThe refresh token from the previous /token response.

Responses

200 OK Same shape as POST /token.

Whether rt_exp moves forward on each refresh depends on the client’s extendRefreshToken registration setting; when it is off, the refresh-token expiry stays fixed from the original login. Presenting a rotated-out refresh token fails with invalid_grant and is logged server-side as suspected token theft.

400 Bad Request invalid_grant — the token is expired, malformed, fails signature/issuer checks, was rotated out, or the session no longer exists. invalid_request — malformed body.

403 Forbidden access_denied — the client is no longer registered.

DELETE/token

Logs out: deletes the server-side session, so the refresh token (and any rotation of it) can no longer be used.

Request body

ParameterTypeDescription
refresh_token requiredstring · JWTThe current refresh token of the session to end.

Responses

200 OK

{
  "statusMessage": "200 OK"
}

Access tokens are stateless JWTs: one that is already issued stays cryptographically valid until its exp. Discard both tokens client-side on logout, and keep access-token lifetimes short.

400 Bad Request invalid_grant — the refresh token is invalid or expired. invalid_request — malformed body.

GET/.well-known/jwks.json

The JSON Web Key Set: the public half of the RSA key(s) the server signs tokens with. Your resource server verifies access tokens against these keys offline — no call to Logintoo per request.

Response

200 OK

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "1bd08b14-8e12-4d16-9d90-98e72c34f56a",
      "n": "rFtQzHx0j0Ws…",
      "e": "AQAB"
    }
  ]
}
  • Select the key whose kid matches the kid in the token’s JWT header.
  • The set normally holds one key; during a signing-key rotation it holds several, so tokens signed by either key verify.
  • The response is cacheable (Cache-Control: public, max-age=3600). Use a verifier that refetches the JWKS when it sees an unknown kid so rotations are picked up promptly.

Errors

Errors use the OAuth 2.0 error codes (RFC 6749 §5.2) in a JSON body:

{
  "statusMessage": "400 Bad Request",
  "error": "invalid_grant",
  "error_description": "The provided authorization grant or refresh token is invalid, expired, or revoked."
}
ErrorStatusMeaning
invalid_request400A required parameter is missing or malformed. Requests rejected by schema validation return {"message": …, "error": "invalid_request"}; the message describes the failing field.
invalid_grant400The authorization code or refresh token is invalid, expired, revoked, already used, or failed PKCE / redirect-URI checks.
invalid_client401The one-time code is wrong or expired, or no login is pending (front-channel /otp).
access_denied403The client or redirect URI is not registered. On the SSO endpoints, also: an unknown or disabled identity provider, an organization this client is not federated with, or an asserted identity outside that organization’s verified domains.
temporarily_unavailable503Transient backend error — retry with backoff.
server_error500Unexpected server error.

Error descriptions are deliberately generic — specifics are logged server-side, never returned to the caller. Under request floods the gateway can also answer 429 Too Many Requests.

A federated login reports failures the OAuth way instead (RFC 6749 §4.1.2.1): once /idp/callback has confirmed the redirect_uri, the same error codes reach your application as error and error_description query parameters on the redirect, not as an HTTP status.

Access-token claims

Access tokens are RS256-signed JWTs (header: {"alg": "RS256", "kid": "<key-id>", "typ": "JWT"}). A decoded payload:

{
  "iss": "https://logintoo.com",
  "token_use": "access",
  "sub": "user@example.com",
  "aud": "api.example.com",
  "iat": 1782000000,
  "exp": 1782003600,
  "jti": "Vd09pXFAcRqxSrUzMHrmcLg3E5EiXvyaXfIVjCu96Ak",
  "email": "User@Example.com",
  "email_verified": true,
  "email_normalized": "user@example.com",
  "hd": "example.com",
  "idp": "otp",
  "amr": ["otp"]
}
ParameterTypeDescription
issstringThe issuer URL configured for the deployment.
token_usestringaccess (refresh tokens carry refresh). Reject a token whose token_use is not access at your API.
substringStable subject identifier: the normalized email address (lower-cased; provider-specific variants such as plus-tags and Gmail dots are collapsed).
audstringAudience — present only when the client is registered with a tokenAud.
iat, expnumberIssued-at and expiry, Unix seconds.
jtistringUnique token ID.
emailstringThe address exactly as the user entered it.
email_verifiedbooleanAlways true — the user proved control of the inbox during this very login.
email_normalizedstringSame value as sub.
hdstringThe domain part of the normalized address.
idpstringWhich method produced this token: otp for the one-time-code flow, or the identity provider’s id for an SSO login. Always present.
amrstring[]Authentication methods reference — [otp] for the one-time-code flow, [ext] for SSO. Always present.
orgstringSSO only — the ID of the organization whose domain the user belongs to.
org_namestringSSO only — that organization’s display name.
idp_substringSSO only — the subject identifier the provider asserted. An audit value, not a cross-application key: some providers issue a sub that is unique per application.
language, localestringEchoed when they were supplied to /auth or /idp/start.

A token from an SSO login carries the same claims, plus the federation ones:

{
  "iss": "https://logintoo.com",
  "token_use": "access",
  "sub": "someone@acme.com",
  "email": "Someone@acme.com",
  "email_verified": true,
  "email_normalized": "someone@acme.com",
  "hd": "acme.com",
  "idp": "acme-entra",
  "amr": ["ext"],
  "org": "01J8ZKACMEHQ4T9V2XKPB3NZDR",
  "org_name": "ACME Corp",
  "idp_sub": "AAAAAAAAAAAAAAAAAAAAAOEmS5T0e8vQ4kK7pQ",
  "groups": ["engineering", "admins"]
}

sub is the normalized email either way, so a user who moves between the two methods stays the same user at your application. Any further claim — groups above — appears only when both the organization and the client have allow-listed it by name, and never overwrites a claim in the table above. Not every provider emits group membership; a Google Workspace ID token, for instance, carries none under any scope.

To verify a token at your resource server:

  • Verify the RS256 signature against the JWKS key matching the header kid — reject unknown kid values.
  • Validate iss against your issuer, exp/iat against the clock, and aud if you configure one.
  • Require token_use == "access" so a refresh token can’t be replayed as an access token.

Limits

Per-client settings (chosen at client registration, within these ranges):

SettingRangeDefault
One-time code length6–8 digits6
Code entry attempts1–104
Code lifetime5–30 min10 min
Access-token lifetime60 s – 24 h1 h
Refresh-token lifetime1 h – 30 d7 d

Fixed server-side:

  • Authorization codes are single-use and live 120 seconds.
  • At most 3 active (unexpired) one-time codes per email address — an anti-email-bombing cap.
  • Gateway throttling: about 50 requests/s steady state, bursts to 100 (429 beyond that).

An organization can additionally cap its own users’ session length: when it sets maxSessionSeconds (5 minutes to 30 days; a value outside that range is clamped into it), the refresh-token expiry for those users is held to that window whatever the client registered — so removing someone from the directory ends their session within it, rather than whenever the client’s own refresh token happens to expire. An organization that sets nothing gets no cap, and the client’s own refresh-token lifetime stands.

Client registration

There is no dynamic registration endpoint. Clients are records in the clients DynamoDB table of your deployment: an id (ULID), the allow-listed redirectURIs, a support address for the code emails (otpEmailFrom), and optional branding and lifetime settings.

SSO is configured the same way, in three more tables: organizations (the customer, its display name and policy), domains (the email domains it has verified), and idps (its OIDC providers). A client opts into federation by listing organization IDs in its orgs field, can narrow which provider claims may reach its tokens with idpClaimsPassthrough, and can drop the one-time code altogether with otpEnabled: false to become SSO-only. There is deliberately no admin API for any of it — a human writes every domain claim, so nobody can claim a domain they do not control.

See Adding a client and the SSO operator runbook in the server documentation for the full record schemas and copy-paste aws dynamodb put-item examples.