Skip to content

Auth endpoints and tokens

The routes the auth block mounts, and the tokens they hand back.

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

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.

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.

A signed-in user moves their own account to another address in two steps:

  1. 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/me reports the waiting address as pendingEmail.
  2. 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 (removedProviders names 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.

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.

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.

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

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 as text, or NULL if 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 a jsonb object:
    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']);

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

Generate a key with:

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out jwt-key.pem

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

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.

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

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.

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.