Skip to content

Secure authentication for your app

The platform builds the safeguards of application sign-in for you. Your job is to choose three identity settings, give the app a working outbound sender, and wire four pages to the platform’s auth routes. Never rebuild any of this from rules and records. This recipe follows the application identity security baseline (ADR 2026-10-25-application-identity-security-baseline) and starts where authentication-basics stops.

You do not configure or code any of these:

  • Normalised, unique email. The identity email is trimmed, Unicode NFC-composed and lower-cased before it is stored or looked up. Marking a field as the identity email makes it unique per application. A database unique index per identity model enforces this on every write path. Registering an email that already exists never creates a second identity.
  • Enumeration-safe answers. A login with an unknown email does the same password-hash work as a known one and gets the same 401 “Invalid email or password”. Under required and optional, registering an existing email gets the same 202 “check your email” answer as a new registration; under off it gets a 409 that does not contain the email. Forgot-password always answers the same 202. Duplicate errors on identity models never contain the email.
  • Limits. Every limit is stored in Postgres and shared by every app node:
    • Login and registration: 5 attempts per 15 minutes for one application + client address + email, and 100 per 15 minutes per client address.
    • Per account: 20 failed logins in an hour for one application + normalised email start a backoff of 1 minute. Each further failure doubles it (2, 4, 8 … minutes) up to 60 minutes. The count starts over after an hour with no failure and no backoff. The backoff ends on its own; no administrator is involved.
    • During a backoff, only a client address that signed in successfully to that email in that application within the last 30 days can still try a password. Every other attempt gets the same 401 “Invalid email or password” as a wrong password, never a 429, so the login route does not reveal which emails are being attacked. An attacker cannot lock out a user who signs in from a device they have used before.
    • Forgot-password: 20 per hour per client address and 5 per hour per application + email. Reset-password: 20 per 15 minutes per client address and 5 per 15 minutes per token prefix. Change-password: 10 per 15 minutes per session. Refresh: 60 per 10 minutes per session and 600 per 10 minutes per client address.
    • Every limit keyed on a client address groups IPv6 addresses by /64, so one subscriber’s rotating addresses share a budget. IPv4 is limited per address. A 429 from these limits carries Retry-After and says nothing about any account.
  • Refresh rotation. Each refresh rotates the refresh token. A consumed token presented again within the overlap interval (3 seconds by default) gets the latest token pair. Presented after the interval, it counts as a replay and revokes the session.
  • Revocation on password change. Any write of the identity password revokes the identity’s sessions, consumer API keys and consumer grants. This covers reset, change-password, a builder record update and a rule action. A change made from inside a signed-in session keeps only that session, re-issued with new tokens. A reset keeps nothing.
  • Password policy. One policy applies to every password write: at least 12 characters (configurable, see below), at most 72 bytes of UTF-8 (refused, never truncated), not equal to the email, and not found in the Have I Been Pwned breach corpus. A refusal is 422 password_policy_failed with rules and minLength; the password is never echoed.

Set these in identity_settings with the application update (PATCH /api/v1/applications/{id} or update_application). Settings use replacement semantics, so send the complete merged identity_settings object.

Setting Values Default Choose
registration_email_verification required, optional, off required Keep required: no session is issued until the user follows the mailed link and signs in. Choose optional only when your rules, pages and services check actor.email_verified. Choose off only when you are deliberately accepting unverified users.
password_min_length 12–128 12 Raise it if your users’ risk calls for it. It cannot be set below 12.
registration_turnstile true, false false Turn it on for any public sign-up form. Send the widget token as data.turnstile_token on the registration POST. A missing or invalid token gets 403 “Registration verification failed” when the platform Turnstile secret is configured. Your custom domain must be in the site key’s allowed hostnames.
verification_redirect_path root-relative path / The page a verification link lands on, with ?email_verification=verified or ?email_verification=invalid.

The mailed reset link always opens /reset-password?token=… on the application’s public origin, so serve your reset page at /reset-password.

Sender prerequisite. required and optional send mail through the tenant’s outbound sender. There is no platform mail fallback. While no sender is configured, saving identity settings with registration enabled and either of those modes is refused, and the error names both fixes: configure a sender, or set registration_email_verification to off on purpose. Any SMTP relay works, including Brevo, SendGrid, Mailgun and SES through their SMTP endpoints; see transactional-email. Password-reset mail goes through the same sender.

Every route is under /api/v1/applications/{application_id}/auth/. Call it from the application’s own host. In the browser, cookie-authenticated non-GET calls send X-Requested-With: XMLHttpRequest. A native shell instead sends Authorization: Bearer <access token> and needs no header.

Page Request Answer
Forgot password POST forgot-password {"email": "…"} Always 202 {"message": "If an account exists for that email, password reset instructions will be sent."}. Show the message. Do not suggest whether the account exists. An unverified identity is sent a new verification link instead.
Reset password (at /reset-password) POST reset-password {"token": "<from ?token=>", "password": "…"} 200 {"reset": true, "sessionsRevoked": true}, then send the user to sign in. 400 “Invalid or expired password reset token.” means the link is used, expired (30 minutes) or wrong: offer forgot-password again. 422 password_policy_failed: show the failed rules.
Change password (signed in) POST change-password {"currentPassword": "…", "newPassword": "…"} 200 {"changed": true, "sessionsRevoked": true}. Every other session is ended. The cookie transport gets fresh session cookies; the bearer transport gets new accessToken and refreshToken and must replace the stored pair. 400 current_password_invalid, 422 password_policy_failed, and 401 for a missing or foreign session.
Sign out everywhere (signed in) POST sign-out-everywhere 200 {"signedOut": true, "sessionsRevoked": true}. Every session, consumer API key and consumer grant of the identity ends, including this session, and the cookies are cleared. Send the user to /login.

The verification link (GET verify-email?token=…) is mailed by the platform. It marks the identity verified and redirects to verification_redirect_path. It never signs the user in.

Render the four pages yourself and post to the routes with the same-origin apiFetch helper from the bring-your-own front-end guide:

await apiFetch(`/api/v1/applications/${appId}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const token = new URLSearchParams(location.search).get('token');
const reset = await apiFetch(`/api/v1/applications/${appId}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});

Serve the reset page at /reset-password. Strip the token from the address bar once you have read it (history.replaceState). Do not log the token or send it to analytics.

Page-builder form blocks support the create, edit, delete, login and logout targets. None of them targets these four routes, and a published page cannot read ?token= and post it. A page-builder app therefore gets everything in “What is automatic”, plus verified registration through its identity-model form, but not self-service reset, change-password or sign-out everywhere. If the app needs those, serve those pages from a bring-your-own front-end on the application’s host as described above. Do not imitate them with rules, records or public record actions.

  • No reset or verification tokens built from rules and records. A token stored in a model is readable through record APIs and is not hashed with a server key. It has no single-use compare-and-set and no expiry, and using it revokes no session. Use forgot-password and reset-password. The platform stores its tokens as HMAC-SHA-256 under a server key, binds each one to its application and identity, and links only to the application’s resolved public origin.
  • No password fields in public record actions. A public record action that lists the identity password field among its writable fields is refused when the model settings are saved. A configuration stored before that rule has the field ignored at run time. Use change-password, which checks the current password and applies the policy and revocation.
  • No custom lockout, rate limit or “account locked” message. The platform’s backoff already keeps a recent user signing in. A per-email 429 or a distinct message tells an attacker which emails exist.
  • No session after registration under required. Do not auto-login after a 202 "emailVerification": "pending" answer, and do not treat a verification link as a sign-in.