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

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=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=S256
    Optional language and locale parameters select the language of the one-time-code email and are carried through to the token claims.
  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.

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 · UUID v4The 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": "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"
  }'

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

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 · UUID v4Same 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.

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 · UUID v4The 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": "00000000-0000-4000-a000-000000000000",
    "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.
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.

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"
}
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.
language, localestringEchoed when they were supplied to /auth.

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

Client registration

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.