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 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 and /otp 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.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=00000000-0000-4000-a000-000000000000
&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.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.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 · UUID v4 | 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": "00000000-0000-4000-a000-000000000000",
"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 or the redirect_uri is not on the client’s
allow-list, the request is dropped after acceptance and no email is sent — deliberately, so the
endpoint can’t be used to probe registered clients.
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 · UUID v4 | 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.
/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 · UUID v4 | 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": "00000000-0000-4000-a000-000000000000",
"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. |
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.
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"
}| 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. |
language, locale | string | Echoed when they were supplied to /auth. |
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).There is no dynamic registration endpoint. Clients are records in the clients DynamoDB
table of your deployment: an id (UUID v4), the allow-listed redirectURIs,
a support address for the code emails (otpEmailFrom), and optional branding and
lifetime settings.
See Adding a client
in the server documentation for the full record schema and a copy-paste aws dynamodb put-item example.