Developer documentation
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.
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-06Content-Type: application/json request bodies and expect JSON back.code_challenge_method=S256.Access-Control-Allow-Origin: *) and every resource answers OPTIONS preflight, so the API is callable directly from the browser.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./token, refresh and logout are identical either way.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=S256language 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.POST /auth; the server emails a one-time code.POST /otp and follows the returned redirect. The browser lands back on your redirect_uri with code, state, and iss query parameters.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.PATCH /token refreshes the pair; DELETE /token logs out./.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).
/authStarts 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.
| Parameter | Type | Description |
|---|---|---|
client_id required | string · ULID | The application’s ID, as registered on the server. |
code_challenge required | string · 43–128 | Base64url-encoded SHA-256 hash of the code_verifier (characters A–Z a–z 0–9 - _). |
code_challenge_method required | string | Must be S256. |
email required | string · ≤ 254 | The address the one-time code is emailed to. |
redirect_uri required | string · URL | Where the user returns after login. Must be an https:// URL and exactly match one of the client’s registered redirect URIs. |
response_type required | string | Must be code (authorization code flow). |
state required | string · 43–128 | Opaque value from your app, echoed back on the redirect. Verify it matches to prevent CSRF. |
language | string · 2 letters | Preferred language of the one-time-code email (e.g. en). Carried through to the redirect and token claims. |
locale | string · xx-XX | Regional locale (e.g. en-CA). Carried through like language. |
cf_turnstile_response | string | Cloudflare Turnstile token. Required — and verified at the edge — only when the deployment has Turnstile bot protection enabled. |
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"
}'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.
/otpExchanges 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).
| Parameter | Type | Description |
|---|---|---|
client_id required | string · ULID | Same value as sent to /auth. |
code_challenge required | string · 43–128 | Same value as sent to /auth — identifies the pending login together with email. |
email required | string · ≤ 254 | The address the code was sent to. |
otp required | string · 6–8 digits | The one-time code from the email. |
302 Found The default: a redirect back to your application.
Location: https://app.example.com/
?code=tzWXCJbHVMOZH0LWLQbSy2DPnrGr0eL1kSMxOprnbXY
&state=hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M
&iss=https%3A%2F%2Flogintoo.com200 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.
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.
The login page calls POST /idp/discover with the
address the user typed, and the reply decides the route:
| Reply | Situation | What the user gets |
|---|---|---|
method: otp | The 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: otpotpFallback: true | The organization is in a staged rollout. | Both: the code form, with the provider offered alongside it. |
method: idp | The 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: none | No 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.
acme.com confers no authority over contractors.acme.com./idp/discoverResolves 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.
| Parameter | Type | Description |
|---|---|---|
client_id required | string · ULID | The application’s ID. |
email | string · ≤ 254 | The 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. |
curl -X POST https://api.example.com/2026-06/idp/discover \
-H 'Content-Type: application/json' \
-d '{
"client_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"email": "someone@acme.com"
}'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"
}| Parameter | Type | Description |
|---|---|---|
method | string | otp — 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). |
otpFallback | boolean | Present 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_name | string | Display name of the organization the address belongs to, for copy such as “You’ll be redirected to ACME Corp to sign in.” |
idp | object | The first entry of idps, for the common single-provider case. |
idps | object[] | 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.
/idp/startBegins 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.
| Parameter | Type | Description |
|---|---|---|
client_id required | string · ULID | The application’s ID, as registered on the server. |
code_challenge required | string · 43–128 | Base64url-encoded SHA-256 hash of the code_verifier. |
code_challenge_method required | string | Must be S256. |
redirect_uri required | string · URL | Where the user returns after login. Must be an https:// URL and exactly match one of the client’s registered redirect URIs. |
response_type required | string | Must be code. |
state required | string · 43–128 | Opaque value from your app, echoed back on the final redirect. |
org_id required | string · ULID | The organization, from /idp/discover. |
idp_id required | string · ≤ 64 | The identity provider within that organization, from /idp/discover. |
language | string · 2 letters | Carried through to the redirect and the token claims. Nothing is emailed on this path, so it selects no email language. |
locale | string · xx-XX | Carried through like language. |
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"
}'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.
/idp/callbackWhere 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/callback — without 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.
| Parameter | Type | Description |
|---|---|---|
state required | string · 43–128 | Logintoo’s own value for this provider leg — not your application’s state. Single-use: it is consumed the first time it resolves. |
code | string | The provider’s authorization code. Present on success. |
error, error_description | string | Sent instead of code when the provider refused the login — the user cancelled, or an administrator policy blocked them. |
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.com302 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| Error | Cause |
|---|---|
access_denied | The 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_unavailable | The organization or the provider was disabled while the login was in flight. |
invalid_request | The provider returned neither a code nor an error. |
server_error | Unexpected 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.
/tokenExchanges 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.
| Parameter | Type | Description |
|---|---|---|
grant_type required | string | Must be authorization_code. |
code required | string · 43–128 | The authorization code from the redirect. |
redirect_uri required | string · URL | The exact redirect_uri used in the authorization request. |
client_id required | string · ULID | The application’s ID. |
code_verifier required | string · 43–128 | The plain PKCE verifier whose SHA-256 hash was sent as code_challenge. |
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"
}'200 OK
{
"statusMessage": "200 OK",
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiZDA4YjE0LTh…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiZDA4YjE0LTh…",
"exp": 1782003600,
"rt_exp": 1782604800,
"state": "hx0Wf3zUUOQFZzXBzKUQdKC0hK0BXf9mmZbcOa9Ig6M"
}| Parameter | Type | Description |
|---|---|---|
access_token | string · JWT | RS256-signed access token — see Access-token claims. |
token_type | string | Always Bearer. |
expires_in | number | Access-token lifetime in seconds (per-client setting, default 3600). |
refresh_token | string · JWT | Rotating refresh token — present it to PATCH /token to get a new pair. |
exp | number | Access-token expiry as a Unix timestamp (seconds). |
rt_exp | number | Refresh-token expiry as a Unix timestamp (seconds). |
state | string | The state of this login, echoed once more. |
language, locale | string | Echoed 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.
/tokenRefreshes 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.
| Parameter | Type | Description |
|---|---|---|
grant_type required | string | Must be refresh_token. |
refresh_token required | string · JWT | The refresh token from the previous /token response. |
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.
/tokenLogs out: deletes the server-side session, so the refresh token (and any rotation of it) can no longer be used.
| Parameter | Type | Description |
|---|---|---|
refresh_token required | string · JWT | The current refresh token of the session to end. |
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.
/.well-known/jwks.jsonThe 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.
200 OK
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "1bd08b14-8e12-4d16-9d90-98e72c34f56a",
"n": "rFtQzHx0j0Ws…",
"e": "AQAB"
}
]
}kid matches the kid in the token’s JWT header.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 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."
}| Error | Status | Meaning |
|---|---|---|
invalid_request | 400 | A required parameter is missing or malformed. Requests rejected by schema validation return {"message": …, "error": "invalid_request"}; the message describes the failing field. |
invalid_grant | 400 | The authorization code or refresh token is invalid, expired, revoked, already used, or failed PKCE / redirect-URI checks. |
invalid_client | 401 | The one-time code is wrong or expired, or no login is pending (front-channel /otp). |
access_denied | 403 | The 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_unavailable | 503 | Transient backend error — retry with backoff. |
server_error | 500 | Unexpected 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 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"]
}| Parameter | Type | Description |
|---|---|---|
iss | string | The issuer URL configured for the deployment. |
token_use | string | access (refresh tokens carry refresh). Reject a token whose token_use is not access at your API. |
sub | string | Stable subject identifier: the normalized email address (lower-cased; provider-specific variants such as plus-tags and Gmail dots are collapsed). |
aud | string | Audience — present only when the client is registered with a tokenAud. |
iat, exp | number | Issued-at and expiry, Unix seconds. |
jti | string | Unique token ID. |
email | string | The address exactly as the user entered it. |
email_verified | boolean | Always true — the user proved control of the inbox during this very login. |
email_normalized | string | Same value as sub. |
hd | string | The domain part of the normalized address. |
idp | string | Which method produced this token: otp for the one-time-code flow, or the identity provider’s id for an SSO login. Always present. |
amr | string[] | Authentication methods reference — [otp] for the one-time-code flow, [ext] for SSO. Always present. |
org | string | SSO only — the ID of the organization whose domain the user belongs to. |
org_name | string | SSO only — that organization’s display name. |
idp_sub | string | SSO 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, locale | string | Echoed 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:
kid — reject unknown kid values.iss against your issuer, exp/iat against the clock, and aud if you configure one.token_use == "access" so a refresh token can’t be replayed as an access token.Per-client settings (chosen at client registration, within these ranges):
| Setting | Range | Default |
|---|---|---|
| One-time code length | 6–8 digits | 6 |
| Code entry attempts | 1–10 | 4 |
| Code lifetime | 5–30 min | 10 min |
| Access-token lifetime | 60 s – 24 h | 1 h |
| Refresh-token lifetime | 1 h – 30 d | 7 d |
Fixed server-side:
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.
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.