Auth endpoints and tokens
The routes the auth block mounts, and the tokens they hand back.
Auth Endpoints
Section titled “Auth Endpoints”All auth endpoints are mounted at /api/auth/:
| Method | Path | Description |
|---|---|---|
POST |
/api/auth/register |
Create a new account |
POST |
/api/auth/login |
Login with email/password |
POST |
/api/auth/refresh |
Refresh the access token |
POST |
/api/auth/<provider> |
OAuth sign-in (e.g., /api/auth/google, /api/auth/linkedin) |
POST |
/api/auth/link/<provider> |
Link an OAuth provider to the authenticated account. On a guest this is registration: it needs allowRegistration, takes the provider’s address when the provider vouches for it, and answers with a session for the account the guest became |
POST |
/api/auth/logout |
Revoke refresh token |
POST |
/api/auth/forgot-password |
Send password reset email |
POST |
/api/auth/reset-password |
Reset password with token |
POST |
/api/auth/find-user |
Resolve an email to a minimal public profile (opt-in — AUTH_ALLOW_USER_LOOKUP) |
POST |
/api/auth/change-password |
Change the caller’s own password (authenticated). Ends every other session and answers with a fresh one for the caller |
GET |
/api/auth/me |
The caller’s own profile |
PATCH |
/api/auth/me |
Update the caller’s own profile |
POST |
/api/auth/change-email |
{ newEmail }: move the caller’s own account to another address. Mails a link to the new address and a notice to the old one; nothing changes until the link is followed. Needs aal2 on an account with a second factor. 409 EMAIL_EXISTS or UNDELIVERABLE_ADDRESS, 400 EMAIL_UNCHANGED, 403 ANONYMOUS_USER for a guest — see Changing an email address |
POST |
/api/auth/confirm-email-change |
{ token } from the link. No session needed. Moves the account onto the new address, verified; 400 INVALID_TOKEN for a spent, replaced or expired link, 409 EMAIL_EXISTS when the address was taken while it waited |
GET |
/api/auth/config |
What this backend offers a sign-in screen — needsSetup, registrationEnabled, passwordReset, emailVerification, magicLink, anonymousLogin, adminPasswordReset, enabledProviders, and storage, which says whether the backend serves file storage. Unauthenticated, and computed from the same predicates the routes enforce, so what the screen advertises cannot drift from what it can do |
POST |
/api/auth/send-verification |
Send the caller an email-verification link |
GET |
/api/auth/verify-email |
Consume a verification link (the URL in that email). Keeps what a live session of the account proves and removes what nobody proved — see Email verification |
POST |
/api/auth/verify-email |
The same with { token, password?, removeUnproven? }: the password keeps it and signs in; with neither proof an account holding one answers 409 PROOF_REQUIRED |
POST |
/api/auth/magic-link |
Email a one-time sign-in link. 503 EMAIL_NOT_CONFIGURED without SMTP |
POST |
/api/auth/magic-link/verify |
Exchange a magic-link token for a session |
POST |
/api/auth/otp |
Email a six-digit sign-in code. Answers the same whether or not the address has an account |
POST |
/api/auth/otp/verify |
Exchange { email, code } for a session |
POST |
/api/auth/anonymous |
Create an anonymous session (opt-in — ALLOW_ANONYMOUS) |
POST |
/api/auth/anonymous/link |
Attach real credentials to the anonymous account already signed in. Needs allowAnonymous and open registration both: 403 ANONYMOUS_AUTH_DISABLED once anonymous sign-in is off, guests signed in before included, and 403 REGISTRATION_DISABLED while registration is closed |
GET |
/api/auth/sessions |
List the caller’s active sessions, one per sign-in. The caller’s own is marked isCurrentSession |
DELETE |
/api/auth/sessions |
Revoke every session, this one included — remote logout on every device |
DELETE |
/api/auth/sessions/:id |
Revoke one session: its refresh token, and the access token that device holds |
GET |
/api/auth/scopes |
Every scope this backend knows, and the ones the caller holds |
GET |
/api/auth/keys |
The caller’s own personal API keys |
POST |
/api/auth/keys |
Create a personal key. 403 PERSONAL_KEYS_DISABLED unless the users collection sets auth.personalKeys |
DELETE |
/api/auth/keys/:id |
Revoke one of the caller’s own keys |
GET |
/.well-known/jwks.json |
The public JWKS — mounted at the root, not under basePath, because that is where a verifier looks. Present when asymmetric signing is configured |
POST |
/api/auth/mfa/enroll |
Start TOTP enrolment (returns the secret and recovery codes) |
POST |
/api/auth/mfa/verify |
Confirm an enrolment with a code from the authenticator |
GET |
/api/auth/mfa/factors |
List the caller’s enrolled factors |
POST |
/api/auth/mfa/challenge |
Open a challenge against a verified factor |
POST |
/api/auth/mfa/challenge/verify |
Answer a challenge — this is what issues the session |
DELETE |
/api/auth/mfa/unenroll |
Remove a factor (requires an aal2 session once the account has a verified factor) |
POST |
/api/auth/mfa/recovery-codes |
Replace the caller’s recovery codes with ten new ones (requires an aal2 session) |
Administrative user and role management is a separate surface, mounted at
/api/admin/ rather than /api/auth/. Reading needs the users:read scope and
changing needs users:write, except the user picker’s narrow read below. An admin and the service key hold both; so does a
role that declares them. Nobody may change an account that holds more than they
do. See Roles and scopes.
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/users |
List users (paginated). ?search= matches a case-insensitive part of the email or display name. On Postgres it also matches any role the user holds, and the uid; on MongoDB it still matches email and display name only |
GET |
/api/admin/users/pick |
The user picker’s read: uid, displayName, email and photoURL per user, nothing else. Takes ?search=, ?limit= (25 by default) and ?offset=, or ?ids=a,b for the users a stored value names. Answers users:read, and also anyone who may write a collection with a userSelect field — so an editor can fill one in. A rule written as raw SQL (using / withCheck the policy model cannot read) cannot be judged ahead, so it counts as refusing here: on such a collection only users:read holders can pick. Anyone else gets 403 SCOPE_MISSING |
POST |
/api/admin/users |
Create a user |
GET |
/api/admin/users/:uid |
Read one user |
PUT |
/api/admin/users/:uid |
Update one user. { disabled: true } switches the account off without deleting it: every sign-in and refresh is refused (ACCOUNT_DISABLED), its sessions end and every token it holds is refused; false switches it back on |
DELETE |
/api/admin/users/:uid |
Delete one user. Their sessions end, and every access token they hold is refused from that request on |
POST |
/api/admin/users/:uid/reset-password |
Reset a user’s password without their current one. rebase auth reset-password calls it, and writes to the database directly only when the backend cannot be reached; either way the account’s sessions end |
DELETE |
/api/admin/users/:uid/mfa |
Remove a user’s second factors and recovery codes, and end their sessions — for someone who lost both |
GET |
/api/admin/roles |
admin and the roles the users collection declares, with their scopes |
POST |
/api/admin/bootstrap |
Let the earliest-registered user claim the admin role while none exists. Refused in production — see First User Bootstrap |
All data API endpoints require a valid Authorization: Bearer <token> header when requireAuth: true (the default).
Password policy
Section titled “Password policy”A new password needs at least 8 characters, with an upper-case letter, a
lower-case letter and a digit. Registration, reset and change refuse one that
fails with 400 WEAK_PASSWORD, listing each rule it broke, and so does an
administrator choosing a password: POST /api/admin/users, a create on the
users collection through the data API (POST /api/data/users, its /bulk, a
socket or MCP write), PUT /api/admin/users/:uid and
POST /api/admin/users/:uid/reset-password. On a /bulk, the message starts
Row <n>: (counted from 0), like the bulk’s other row errors.
To create accounts whose passwords another system set, such as an import, send
skipPasswordPolicy: true in the create body (on each row of a /bulk). The
server honours it only for an administrator or the service key; from anyone
else it waives nothing, and the refusal says so. It is never stored. The admin
panel’s import sends it for every row that carries a password. Sign-in
never checks the policy, so an older password keeps working. To set your own rules, pass
auth.hooks.validatePasswordStrength from an ejected
backend. The first admin seeded from REBASE_ADMIN_PASSWORD is held to a
different rule: at least 12 characters, and nothing else.
Response format
Section titled “Response format”Every endpoint that issues a session answers with the same envelope — register,
login, each OAuth provider, magic-link/verify, otp/verify, anonymous,
anonymous/link and mfa/challenge/verify:
{ "user": { "uid": "8f1c2a6e-…", "email": "jane@example.com", "displayName": "Jane Doe", "photoURL": null, "providerId": "password", "isAnonymous": false, "emailVerified": true, "roles": ["editor"], "metadata": {} }, "tokens": { "accessToken": "eyJhbGciOi…", "refreshToken": "9b2e…", "accessTokenExpiresAt": 1700000000000 }}Send the access token back as Authorization: Bearer <accessToken>.
accessTokenExpiresAt is epoch milliseconds.
POST /api/auth/refresh answers with the same envelope, except that user is
omitted entirely when the account cannot be re-read, so treat it as optional
there.
providerId says how the session was signed in: password, anonymous,
magic-link, otp, mfa (a sign-in finished with a second factor), or the
provider’s id, such as google. It is stored with the session at sign-in, so
refresh and GET /api/auth/me give the same answer for as long as the
session lives. A session signed in before 0.24 reads password.
Changing an email address
Section titled “Changing an email address”A signed-in user moves their own account to another address in two steps:
POST /api/auth/change-email { newEmail }records the change and mails a link,<frontend>/confirm-email-change?token=…, to the new address, and a notice without a link to the current one. The link lives 24 hours, and a new request replaces the last.GET /api/auth/mereports the waiting address aspendingEmail.POST /api/auth/confirm-email-change { token }moves the account: the new address becomes its address, verified. Every OAuth identity whose provider vouched for the old address is detached (removedProvidersnames them), because whoever controls the old address could otherwise still sign in through it, and any reset link mailed to the old address stops working. Sessions are kept.
The new address is not reserved while the link waits: holding it would let any
account keep a stranger from signing up with their own address. If another
account has the address by the time the link is followed, the link answers
409 EMAIL_EXISTS and nothing moves; of two accounts asking for the same
address, the first to follow its link gets it. The beforeEmailChange hook can
refuse an address, as beforeUserCreate does at sign-up.
In the CMS, the address is changed from Account settings → Profile, and the
link opens the CMS’s own screen, signed in or not. Another frontend serves a page
at /confirm-email-change that calls the route with the link’s token.
With cookieAuth enabled the refresh
token travels as an httpOnly cookie and tokens.refreshToken is an empty
string in the body. The access token is unaffected.
Multi-factor authentication (TOTP)
Section titled “Multi-factor authentication (TOTP)”A second factor gates sign-in, not just individual operations. Once an
account has one verified TOTP factor, no route issues it a session until a
code is presented — password login, every OAuth provider, magic link and
anonymous-link all refuse with 401 MFA_REQUIRED:
{ "error": { "code": "MFA_REQUIRED", "message": "Multi-factor authentication is required to complete sign-in.", "details": { "mfaToken": "<short-lived pre-auth token>", "factors": [{ "id": "…", "factorType": "totp", "friendlyName": "Phone" }] } }}mfaToken is not a session: it is purpose-scoped, expires in five minutes,
and is rejected by every authenticated route. Send it as the bearer token to
POST /api/auth/mfa/challenge (with a factorId) and then to
POST /api/auth/mfa/challenge/verify (with the challengeId and the six-digit
code, or a recovery code). That last call is what mints the access and refresh
tokens, at aal2; the level is stored on the session and carried across
POST /api/auth/refresh.
Enrolment is gated too. The first factor on an account may be enrolled from an
ordinary session, but once one is verified, enroll, verify and unenroll
all require an aal2 session — otherwise a stolen password could enrol a
factor of its own, step up on it, and delete the real one.
Verification is bounded on both axes: a challenge dies after five failed guesses, each account is limited to ten verification attempts per 15 minutes (counted per user, so rotating IPs does not help), and an accepted code is recorded against the factor so it cannot be replayed for the rest of its ±1-step window.
In the CMS users table, Reset two-step verification
(DELETE /api/admin/users/:uid/mfa) and Disable or enable account
(PUT /api/admin/users/:uid { disabled }) are offered to whoever holds
users:write, as the routes are; the switch is never offered on your own
account, and an account that outranks you is refused with the server’s reason.
Set MFA_ENCRYPTION_KEY (32+ random characters) to encrypt stored TOTP
secrets. Without it the server falls back to JWT_SECRET and warns. Set it
before anyone enrols: stored secrets carry no key id, so changing the key
afterwards leaves existing factors undecryptable and their owners unable to
complete a challenge.
Inviting teammates by email
Section titled “Inviting teammates by email”Invite flows need to turn an email address into a user id, but the users
collection is RLS-protected from the client. Instead of hand-rolling an admin
server function, opt into the built-in lookup:
await initializeRebaseBackend({ auth: { // ... allowUserLookup: true, // enables POST /api/auth/find-user },});Then, from the client:
const profile = await client.auth.findUserByEmail("teammate@example.com");// → { uid, displayName, photoURL } | null (never email/roles/metadata)if (profile) { await client.data.team_members.create({ team_id, user_id: profile.uid });}The endpoint is authenticated-only and returns just uid, displayName,
and photoURL — never the email, roles, or metadata of the looked-up user. It
is off by default because it lets any signed-in user probe which emails have
accounts; enable it only when your invite UX needs it.
Row-Level Security (RLS) Database Context
Section titled “Row-Level Security (RLS) Database Context”Rebase bridges request authentication directly down to PostgreSQL Row-Level Security (RLS). Every database query executed through a user-scoped driver runs inside a database transaction (db.transaction()) that configures transaction-local configuration parameters:
app.user_id— The authenticated user’s unique ID (uid). Defaults to'anon'for unauthenticated requests.app.user_roles— A comma-separated string listing the user’s assigned roles.app.jwt— A JSON string containing the full JWT claims payload ({"sub": "<uid>", "roles": [...]}).
These parameters are configured locally for the duration of the transaction using Postgres’s set_config function:
SELECT set_config('app.user_id', $1, true), set_config('app.user_roles', $2, true), set_config('app.jwt', $3, true);PostgreSQL Policy Helper Functions
Section titled “PostgreSQL Policy Helper Functions”To make writing Row-Level Security policies simple, Rebase creates helper functions under the auth schema during database bootstrapping:
rebase.uid()— Returns the authenticated user’s ID astext, orNULLif not set:CREATE OR REPLACE FUNCTION rebase.uid() RETURNS text AS $$SELECT NULLIF(current_setting('app.user_id', true), '');$$ LANGUAGE sql STABLE;rebase.roles()— Returns the comma-separated roles string:CREATE OR REPLACE FUNCTION rebase.roles() RETURNS text AS $$SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');$$ LANGUAGE sql STABLE;rebase.jwt()— Returns the full JWT payload as ajsonbobject:CREATE OR REPLACE FUNCTION rebase.jwt() RETURNS jsonb AS $$SELECT COALESCE(NULLIF(current_setting('app.jwt', true), ''), '{}')::jsonb;$$ LANGUAGE sql STABLE;
You can use these helpers directly in your custom security rules or database migrations:
CREATE POLICY owner_access ON posts FOR ALL TO public USING (author_id = rebase.uid() OR string_to_array(rebase.roles(), ',') && ARRAY['admin']);Asymmetric Tokens and JWKS
Section titled “Asymmetric Tokens and JWKS”By default access tokens are signed with jwtSecret (HS256). That works, but it
means anything that needs to verify a token has to hold the key that mints
one — so a gateway or an edge worker checking a session can also forge one — and
changing the secret signs every user out at once.
Configure a signing key and Rebase signs access tokens asymmetrically instead,
publishing the public half at /.well-known/jwks.json for anyone to verify
against:
auth: { jwtSecret: process.env.JWT_SECRET, signingKeys: [ { kid: "2026-08", privateKey: process.env.JWT_PRIVATE_KEY! } ]}Or from the environment, for a single key:
JWT_PRIVATE_KEY="$(cat jwt-key.pem)"JWT_KEY_ID=2026-08Generate a key with:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pemRSA keys work too and sign RS256; EC P-256 keys sign ES256. Only the private
key is configured — the public half is derived from it, so the pair cannot be
mismatched. jwtSecret stays required either way: it still signs the
purpose-scoped tokens (download links, MFA-pending, password reset) that only
this server ever reads.
Rotating a key
Section titled “Rotating a key”Put the new key first and keep the old one listed. New tokens are signed by the new key; tokens already in circulation keep verifying against the old one until they expire, so nobody is signed out.
signingKeys: [ { kid: "2026-09", privateKey: process.env.JWT_PRIVATE_KEY_NEW! }, { kid: "2026-08", privateKey: process.env.JWT_PRIVATE_KEY_OLD! }]Once the longest access-token lifetime has passed, drop the old entry. Use
activeKid if you want to publish a key before signing with it.
Verifying elsewhere
Section titled “Verifying elsewhere”Tokens carry the signing key’s kid in their header, which is how a verifier
picks the right key out of the JWKS and how it knows to re-fetch after a
rotation. Any standard library does this for you — for example, with jose:
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://api.example.com/.well-known/jwks.json"));const { payload } = await jwtVerify(token, jwks);Service Key Authentication
Section titled “Service Key Authentication”For server-to-server communication (e.g., cron jobs, external services), configure a static service key:
auth: { serviceKey: process.env.REBASE_SERVICE_KEY, // ...}Clients authenticate with the Authorization: Bearer <service-key> header.
Internal Per-Boot Key
Section titled “Internal Per-Boot Key”If REBASE_SERVICE_KEY is not provided in your configuration, Rebase automatically generates a random internal per-boot key.
This key is never logged and never leaves the process. It is used by the rebase singleton to authenticate against the server’s own control-plane APIs (auth, storage, etc.). This ensures that administrative tasks (like sending a welcome email or generating a storage URL) always function out-of-the-box in development and production without requiring manual key management.
Timing-Attack Protection & Key Requirements
Section titled “Timing-Attack Protection & Key Requirements”To prevent timing attacks, Rebase validates both the user-configured service key and the internal key using constant-time string comparison (safeCompare). The user-configured service key must be at least 32 characters long; if a key shorter than 32 characters is configured, Rebase will throw a configuration error on startup and fail-closed.
Next Steps
Section titled “Next Steps”- Authentication — the configuration these routes come from
- Custom auth adapters — replacing the provider behind them
- Security Rules (RLS) — what a policy does with
rebase.uid() - Typed SDK Authentication — calling these routes from the SDK