Authentication
Authentication is three pages, because it is three jobs. This one is configuration: what goes in the auth block and in the environment.
- Endpoints and tokens — the routes the backend mounts, the response shapes, MFA, the database context a policy sees, JWKS and service keys.
- Custom auth adapters — replacing the built-in provider with Clerk, Firebase Auth or your own.
Overview
Section titled “Overview”Rebase includes a complete backend authentication system:
- JWT tokens — Access and refresh token flow with configurable expiration
- OAuth providers — Google, LinkedIn, GitHub, Microsoft, Apple, and more
- SMTP email — Password reset and email verification flows
- Auth hooks — Lifecycle hooks for user creation and more
- Custom auth adapters — Plug in Firebase Auth, Auth0, Clerk, or any external provider
- Service key — Static key for server-to-server authentication
- Auto-bootstrapping — Outside production, the first user automatically gets the admin role; a production deployment names its admin with
REBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORD
Configuration
Section titled “Configuration”The auth block in initializeRebaseBackend controls all backend authentication:
const backend = await initializeRebaseBackend({ // ... auth: { collection: usersCollection, // Your users collection definition jwtSecret: env.JWT_SECRET, // Required — signing secret accessExpiresIn: "1h", // Access token lifetime (default: 1h) refreshExpiresIn: "30d", // Refresh token lifetime (default: 30d) serviceKey: env.REBASE_SERVICE_KEY, // Optional — for server-to-server calls allowRegistration: true, // Allow new signups (default: false)
// OAuth providers google: env.GOOGLE_CLIENT_ID ? { clientId: env.GOOGLE_CLIENT_ID } : undefined,
// SMTP email (for password reset, email verification) email: env.SMTP_HOST ? { from: env.SMTP_FROM || `${env.APP_NAME} <noreply@example.com>`, smtp: { host: env.SMTP_HOST, port: env.SMTP_PORT, // 587 for TLS, 465 for SSL secure: env.SMTP_SECURE, // true for port 465 auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS! } : undefined, name: env.SMTP_NAME, // Optional EHLO/HELO hostname timeoutMs: 30_000, // Connect, greeting and silence limit (the default) }, appName: env.APP_NAME, logoUrl: env.EMAIL_LOGO_URL, // Logo shown atop the default templates resetPasswordUrl: env.FRONTEND_URL, // URL for password reset page } : undefined,
// Lifecycle hooks hooks: { afterUserCreate: async (user) => { console.log(`New user registered: ${user.email}`); } } }});The auth block, in full
Section titled “The auth block, in full”| Key | Type | Default | What it does |
|---|---|---|---|
collection |
CollectionConfig |
— | The users collection. See Collection-Level Auth Configuration |
jwtSecret |
string |
— | HS256 signing secret. Required in production |
signingKeys |
JwtSigningKeyConfig[] |
— | Asymmetric signing keys — see Asymmetric Tokens and JWKS |
activeKid |
string |
first key | Which of signingKeys mints new tokens |
accessExpiresIn |
string |
1h |
Access-token lifetime |
refreshExpiresIn |
string |
30d |
Refresh-token lifetime. Sliding: each rotation re-ups it, so this is how long a session survives inactivity. The runtime passes JWT_REFRESH_EXPIRES_IN, which defaults to 30d too. Browsers cap the refresh cookie at 400 days, so a longer value buys nothing in cookie mode |
refreshTokenReuseIntervalSeconds |
number |
10 |
How long a refresh token that was rotated away still mints a sibling of its session, so a client that lost a refresh answer is not signed out |
refreshTokenReuse |
"reject" | "revoke-session" |
"reject" |
What a refresh token presented after that window does. "reject" refuses it (TOKEN_ALREADY_USED) and logs it, and the session stands: this is not reuse detection, since whoever refreshed first keeps the session. "revoke-session" ends the whole sign-in on such a replay (SESSION_REVOKED), as GoTrue does, and the owner signs in again. AUTH_REFRESH_TOKEN_REUSE |
requireAuth |
boolean |
true |
Require a session for the data API |
allowRegistration |
boolean |
false |
Open POST /api/auth/register. Outside production the first user on an empty table is admitted either way; in production the admin is named with REBASE_ADMIN_EMAIL |
disableSelfRegistration |
boolean |
false |
Kill switch: also closes the first-user bootstrap window that allowRegistration: false leaves open |
allowAnonymous |
boolean |
false |
Enable POST /api/auth/anonymous. Deliberately not gated by allowRegistration — a public read-mostly app can want sessions without accounts. Turning a guest into an account (POST /api/auth/anonymous/link) is registration, and needs allowRegistration too — and allowAnonymous still on: switched off, it also refuses the guests already signed in, so none can become an account (403 ANONYMOUS_AUTH_DISABLED) |
allowUserLookup |
boolean |
false |
Mount POST /api/auth/find-user for invite-by-email flows |
defaultRole |
string |
— | Role given to a newly registered user when none is specified. It may not be admin, or a declared role that holds an admin-plane scope: the boot refuses both |
serviceKey |
string |
— | Static key for server-to-server calls — see Service Key Authentication |
email |
EmailConfig |
— | SMTP, for password reset, verification, invitations and magic links |
magicLink |
boolean |
false |
Enable passwordless email sign-in. Needs email configured; without it the routes answer 503 EMAIL_NOT_CONFIGURED |
emailOtp |
boolean |
false |
Enable six-digit sign-in codes by email — see One-time codes. Same email requirement |
magicLinkCreatesUsers |
boolean |
false |
Passwordless sign-up: a magic-link or email-code request for an address with no account creates one (no password, unverified until the link or code is used), while allowRegistration is on. Runs beforeUserCreate and the default role. Off, those requests create nothing and answer an unknown address as they answer a known one. AUTH_MAGIC_LINK_CREATES_USERS |
requireEmailVerification |
boolean |
false |
Refuse password sign-in until the address is verified, and register confirm-first — see Email verification. Needs email; the boot refuses it without |
cookieAuth |
CookieAuthConfig |
— | Deliver the refresh token as an httpOnly Secure SameSite cookie instead of in the JSON body — see below |
providers |
OAuthProvider[] |
[] |
The canonical OAuth array; the named provider fields resolve into it |
allowedRedirectUris |
string[] |
— | Narrow which redirect URIs the OAuth routes accept |
hooks |
AuthHooks |
— | beforeUserCreate, afterUserCreate, afterUserDelete, … |
Refresh tokens in an httpOnly cookie
Section titled “Refresh tokens in an httpOnly cookie”auth: { cookieAuth: { sameSite: "Lax" } }The refresh token is the long-lived credential, and in the default JSON-body
mode any XSS on the page can read it. cookieAuth moves it into a cookie the
page’s own JavaScript cannot touch. The access token stays in the JSON body,
because the client has to put it in an Authorization header.
Two things have to follow, or sign-in breaks rather than degrades: client fetches
to the auth endpoints need credentials: "include", and CORS has to allow
credentials — which means an explicit origin list, never origin: "*".
AUTH_COOKIE_SAME_SITE is the environment spelling of sameSite, and
AUTH_COOKIE_SECURE of secure.
The cookie carries Secure unless you turn it off, and nothing about the request
can: the flag used to be read from the request protocol, which is http behind
any TLS-terminating proxy, so the refresh token travelled in cleartext in the
commonest production topology. AUTH_COOKIE_SECURE=false is the one way out, for
a deployment genuinely served over plain http — a LAN address, an appliance — and
it warns at boot. http://localhost does not need it: browsers treat it as a
trustworthy origin and accept Secure cookies there.
| Key | Default | |
|---|---|---|
cookieName |
__rb_refresh |
|
domain |
current domain | |
path |
/ |
|
sameSite |
Lax |
None is only for a genuinely cross-site frontend |
secure |
true |
Secure by default; AUTH_COOKIE_SECURE=false for plain http |
Bot protection
Section titled “Bot protection”Rate limiting bounds one caller. A thousand addresses sending one request each
never touch a per-IP window — and /auth/register, /auth/forgot-password and
/auth/magic-link all send mail, so the bill for an unprotected form is paid in
reputation on your sending domain.
auth: { captcha: { enabled: true, provider: "turnstile", // or "hcaptcha" secret: process.env.CAPTCHA_SECRET }}Or from the environment, which is what a managed deployment has:
CAPTCHA_PROVIDER=turnstileCAPTCHA_SECRET=...CAPTCHA_ROUTES=register,forgotPassword,magicLink,emailOtp # optional; this is the defaultThe client sends the widget’s token as captchaToken in the JSON body, or in
the widget’s own cf-turnstile-response / h-captcha-response header. Both are
accepted; set tokenField to use a different body key.
login is not protected by default. A challenge on every sign-in taxes every
real user, and credential stuffing is what the rate limiter and account lockout
are for. Add it to routes if you want it.
POST /auth/anonymous/link, where a guest gets an email and a password, counts
as registration: it takes the register challenge and runs beforeUserCreate,
as /auth/register does. Guest sign-in itself (POST /auth/anonymous) takes no
challenge.
It fails closed
Section titled “It fails closed”If the provider cannot be reached, verification fails and the request is refused. An attacker able to cause that outage could otherwise switch the protection off, which is the one thing a challenge must not allow.
The cost is that a provider outage blocks sign-ups. That is loud, visible, and undone by removing one config key — a better failure than a silent one noticed when the mail domain gets blocklisted.
A misconfiguration fails the boot
Section titled “A misconfiguration fails the boot”enabled: true with no provider, an unknown provider, or no secret all refuse to
start. A challenge that is silently absent while the config says it is there is
the one failure this cannot have.
The caller is told only that the challenge failed — never whether the token was absent, malformed, already used, or unverifiable. Which one it was goes to the log, because telling a script tells it how to get closer.
Email in development
Section titled “Email in development”Without SMTP_HOST, auth mail has nowhere to go. Rather than refusing the
request, a development server captures the message and prints its links:
⚠️ No SMTP is configured, so auth email is being captured here instead of sent.ℹ️ [email] Sign in to Acme → you@example.com http://localhost:5173/auth/magic-link?token=…Follow the link and the flow completes, on whatever page serves that path: the
CMS serves /reset-password, /verify-email, /confirm-email-change and a
magic link’s /auth/magic-link; another frontend needs a page of its own for
each (see the SDK’s Magic Links). Nothing about the token
changes — it is minted, stored and validated exactly as it would be from a
real inbox; only delivery is different.
This is on whenever all three hold, and there is no setting that changes them:
SMTP_HOSTis unset — a configured mail server always wins;NODE_ENVis notproduction. A captured password-reset mail carries a working reset token, so the capture buffer is a credential store and must not exist in production;FRONTEND_URLis an absolutehttp(s)URL, because otherwise the emailed link has no base and would be dead on arrival.
If any of them does not hold, POST /auth/magic-link and
POST /auth/forgot-password answer 503 EMAIL_NOT_CONFIGURED as before. In
production, set SMTP_HOST (or auth.email.sendEmail) to send mail for real.
Reading the captured mail without a terminal
Section titled “Reading the captured mail without a terminal”The log is only useful to someone watching it. A server in Docker, a second window, or a scrolled-past line all leave a link that was printed and cannot be found — so the same capture is served over HTTP:
GET /api/admin/dev/emails → { enabled: true, messages: [ … ] }DELETE /api/admin/dev/emails → empties the mailboxEach message carries to, subject, at, the html and text parts, and
links — the absolute URLs found in the body, in document order, which is the
part anyone actually wants.
It needs the users:write scope, through the same gate cron, logs and backups use, and it
answers 501 DEV_MAILBOX_UNAVAILABLE when there is nothing to serve — with SMTP
configured, mail was delivered rather than held. NODE_ENV=production refuses
it regardless of anything else: what these messages contain is a working login.
One-time codes by email
Section titled “One-time codes by email”A magic link opens the session on whichever device holds the mailbox. That is the right device on a laptop and the wrong one everywhere else — a television, a terminal, a second browser, a kiosk. A code crosses that gap, because a person carries it across.
auth: { emailOtp: true, // or AUTH_EMAIL_OTP=true email: { /* … */ }}await rebase.auth.sendEmailOtp("someone@example.com");// …the person reads six digits out of their inbox…const { user } = await rebase.auth.verifyEmailOtp("someone@example.com", "384102");The address is sent again with the code, and that is not a convenience. What is stored is a hash of the address and the code together, so a guess is a guess against one named account — not against every account in the table at once, which is what a code-only lookup would make of a million possibilities.
The rest of what keeps six digits sufficient:
- Ten minutes, and single use.
- Five verification attempts per address per window, keyed on the address
rather than the caller’s IP: an IP is the attacker’s to rotate and the account
under attack is not. Counts live wherever the deployment’s rate-limit store
does — per replica by default, shared with
REBASE_RATE_LIMIT_STORE=sql. - Uniform digits, from
randomIntrather than a modulo of random bytes. POST /auth/otpanswers identically for an address with no account, so it cannot be used to ask whether somebody is a customer.
Reading a code out of the inbox proves the address, so a successful sign-in marks it verified — exactly as following a magic link does. On an account that was not verified yet, that first proof also removes what nobody proved: the password and every linked identity whose provider did not verify the address. See Account Linking.
Branding the default emails
Section titled “Branding the default emails”The built-in password-reset, verification, invitation, welcome, magic-link and
email-change templates render a logo above the card. It comes from email.logoUrl:
email: { // … appName: "Acme", logoUrl: "https://acme.example/logo.png" // 48×48, absolute https URL}It must be a PNG or JPG on an absolute http(s) URL. Mail clients do not
render SVG and block data: URIs, and the image is fetched by the recipient’s
client rather than by your server — so a relative path, a data URI or a local
file renders no logo rather than a broken image. appName is used as the alt
text, so a client with images turned off still shows the name.
The fallback is deliberately asymmetric. appName falls back to Rebase, but
the logo only falls back to the Rebase mark while the install has not renamed
itself. Set appName to anything else and you get no logo until you set
logoUrl — otherwise Acme’s users would receive a Rebase mark in mail signed by
Acme’s domain.
If you replace a template through email.templates, none of this applies: your
function owns the whole body. templates.emailChange(confirmUrl, user, newEmail)
is the link mailed to the new address when a user changes theirs, and
templates.emailChangeNotice(user, newEmail) the notice mailed to the old one;
user.email is the current address in both.
OAuth Providers
Section titled “OAuth Providers”Each OAuth provider is configured with at minimum a clientId. Some providers require a clientSecret:
auth: { google: { clientId: "..." }, linkedin: { clientId: "...", clientSecret: "..." }, github: { clientId: "...", clientSecret: "..." }, microsoft: { clientId: "...", clientSecret: "...", tenantId: "..." }, apple: { clientId: "...", teamId: "...", keyId: "...", privateKey: "..." }, facebook: { clientId: "...", clientSecret: "..." }, twitter: { clientId: "...", clientSecret: "..." }, discord: { clientId: "...", clientSecret: "..." }, gitlab: { clientId: "...", clientSecret: "..." }, bitbucket: { clientId: "...", clientSecret: "..." }, slack: { clientId: "...", clientSecret: "..." }, spotify: { clientId: "...", clientSecret: "..." },}gitlab also takes an optional baseUrl, for a self-hosted GitLab instance.
The environment spellings
Section titled “The environment spellings”A managed or bundle deployment has no auth block to write in — it configures
the server entirely through the environment — so every provider above has a
<PROVIDER>_CLIENT_ID / <PROVIDER>_CLIENT_SECRET pair, and both halves have to
be set before the provider is configured at all:
DISCORD_CLIENT_ID=…DISCORD_CLIENT_SECRET=…GET /api/auth/config then lists discord in enabledProviders, which is how
to check that a pair arrived.
Apple is the exception: it has no static client secret, because Rebase signs a
short-lived ES256 JWT for each token exchange. It needs all four of
APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID and APPLE_PRIVATE_KEY — the
.p8 file’s contents, newlines and all.
Two options have no environment spelling and need the auth block (so, an
ejected or code-configured backend): microsoft.tenantId, which otherwise
defaults to common and reports every address as unverified, and
gitlab.baseUrl, for a self-hosted instance.
Each named field is resolved at startup into auth.providers, which is the
canonical array and the extension point for anything the named fields do not
cover. Entries are built with the create*Provider factories, and the two forms
merge — named fields are appended after explicit entries:
import { createGoogleProvider, createGitHubProvider } from "@rebasepro/server";
auth: { providers: [ createGoogleProvider({ clientId: "…", clientSecret: "…" }), createGitHubProvider({ clientId: "…", clientSecret: "…" }) ]}Narrowing the redirect URIs
Section titled “Narrowing the redirect URIs”auth: { allowedRedirectUris: ["https://admin.example.com/"] }Left unset, the only check on an OAuth redirect is the provider’s own
registered-URI match — which authorises every URI registered on that OAuth
client, including the localhost entry someone added for development and the
staging host nobody removed. Listing the origins this backend actually serves
narrows it to those. URIs are compared on origin plus path; query, fragment and
a trailing slash are ignored.
Account Linking Across Sign-In Methods
Section titled “Account Linking Across Sign-In Methods”What happens when someone registers with email/password as ada@example.com,
then later clicks “Sign in with Google” on a Google account with that same
address? Rebase links the two into one account — but only when the provider
asserts the email as verified and the account’s own address has been verified.
It never silently creates a second account for the same address.
On POST /api/auth/<provider> the resolution order is:
- Known provider identity — if this exact provider identity has signed in before, that user is returned. The email is not consulted.
- Existing account with the same email, both sides verified — the provider verified the email, and so has the account. The identity is attached to the existing account and the user is signed in to it. One account, two ways in.
- Existing account with the same email, either side unverified —
rejected with
403 EMAIL_NOT_VERIFIED. Nothing is created or modified.details.reasonsays which side:provider-email-unverified, orlocal-account-unverified(the account has a password) /local-account-unverified-passwordless(it does not). - No account with that email — a new account is created, verified if the provider verified the email.
Step 3 is the security-critical case. If an unverified provider email were
enough to link, anyone who could get a provider to emit an address they don’t
own could take over the matching Rebase account. Google always asserts
email_verified for real Google accounts, so step 2 is the normal path for
Google sign-in; step 3 mostly catches providers that let users supply an
arbitrary unconfirmed address.
The account’s side matters for the same reason. Nothing verifies the address
POST /auth/register is given, so anyone can register someone else’s address
with a password, or sign in as it through a provider that does not vouch for
it, and wait. Linking the owner’s Google sign-in onto that account would leave
the other person’s way in on it.
A verification link, a magic link, an email code or a password reset proves the address and verifies the account. On an account that was not verified yet, the first such proof removes the password (a reset sets the new one) and every linked identity whose provider did not verify that address, and ends every session, before it marks the account verified. After that, step 2 applies.
A verification link keeps what the person following it also proves: a live
session of the account, or its password. See Email verification. Accounts created by an
admin with POST /api/admin/users are stored verified, so an invitee can use
“Sign in with Google” straight away. A custom auth repository without
unlinkUserIdentity refuses such a proof with 409 UNVERIFIED_IDENTITIES when
there is an identity to remove.
This behavior is not configurable — there is deliberately no option to link on unverified emails.
Email verification
Section titled “Email verification”Registering mails the new account a
verification link, and following it keeps only what the person following it
also proves. requireEmailVerification makes registration confirm-first. See
Email verification.
Auto-Created Tables
Section titled “Auto-Created Tables”On first startup, Rebase automatically provisions the auth schema and the following tables in the database (bound to the schema defined in your collection, e.g., rebase):
rebase.users— User accounts with email, password hash, metadata, and arolestext[] column (roles are stored as inline text arrays to optimize queries and avoid joins).rebase.refresh_tokens— Long-lived sessions carrying hashed refresh tokens, user agents, and IP addresses. Includes a unique index ontoken_hash. One sign-in is onesession_id, shared by every token rotated out of it; there is no per-device constraint, so two browsers behind one address are two sessions. Every token of a session also carries its assurance level (aal) and how it was signed in (method, whichproviderIdreports).rebase.password_reset_tokens— Expirable single-use tokens for password recovery flows.rebase.mfa_factors— Enrolled multi-factor authentication methods (e.g. TOTP secrets encrypted with AES-256).rebase.mfa_challenges— Verification logs tracking active MFA verification attempts.rebase.recovery_codes— Hashed multi-factor backup/recovery codes.rebase.app_config— Key-value store for system configurations.
Tokens that expire — reset links, magic links and email codes, refresh tokens,
MFA challenges — are deleted once an hour after they expire. The sweep runs on
the process that owns the timers (the worker role in a split deployment) and
on one instance of the fleet: each hour is claimed in rebase.cron_claims
under the job id rebase:auth:expired-tokens, as a cron slot is. With
cronPersistence: false there is no claims table, and every such instance
sweeps. An expired token is refused when presented whether or not the sweep has
run; the sweep only keeps the tables from growing.
First User Bootstrap
Section titled “First User Bootstrap”When no users exist in the database and the server is not running with NODE_ENV=production, the first person to register automatically becomes an admin. After that, registration is controlled by the allowRegistration setting.
In production that window is closed, because a host with a public name is reachable before its operator has registered, and whoever got there first would own it. A production deployment names its first admin in the environment instead — REBASE_ADMIN_EMAIL and REBASE_ADMIN_PASSWORD, created at boot while the table is still empty — or assigns the role with the service key. With the window closed, an empty table refuses the bootstrap registration with SETUP_REQUIRED (and says so), a first account created through open registration is an ordinary account, GET /api/auth/config never reports needsSetup, POST /api/admin/bootstrap refuses, and the boot log warns when the table is empty and no admin is named.
On a laptop this means you can always bootstrap a fresh database without seeding it manually. To prevent concurrent runs and schema generation race conditions on hot reloading (HMR) or startup, bootstrapping operations are synchronized using a Postgres advisory lock:
SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'));Collection-Level Auth Configuration
Section titled “Collection-Level Auth Configuration”Instead of relying solely on the default database auth rules, you can mark any Postgres collection (such as users.ts or a custom members.ts collection) as the authentication collection. This is configured via the auth property on the collection itself:
import { randomBytes } from "node:crypto";import { defineCollection } from "@rebasepro/cms-types";
const membersCollection = defineCollection({ name: "Members", slug: "members", table: "members", auth: { enabled: true,
// Customize what happens when an admin creates a user via the REST API onCreateUser: async (values, ctx) => { const hash = await ctx.hashPassword("welcome123"); return { values: { ...values, passwordHash: hash, emailVerified: true }, temporaryPassword: "welcome123" }; },
// Customize what happens when an admin resets a user's password in the admin panel onResetPassword: async (userId, ctx) => { const tempPassword = randomBytes(12).toString("base64url"); return { temporaryPassword: tempPassword, // saved as the new password, then shown to the admin invitationSent: false }; },
// Inject/override auth-specific actions (e.g. show/hide the reset password button) actions: { resetPassword: true // Or false to disable, or a custom EntityAction },
// What each role may do beyond its rows, the app's own scopes, // and whether accounts may create personal API keys roles: { support: { name: "Support", scopes: ["users:read", "users:write", "logs:read"] } }, scopes: { "project:deploy": { label: "Deploy projects", target: "project" } }, personalKeys: true }, properties: { ... }});roles, scopes and personalKeys declare the access model: which admin-plane
scopes each role holds, the scopes the app defines for its own operations, and
whether each account may mint personal API keys.
See Roles and scopes.
A temporaryPassword returned from onResetPassword becomes the account’s password. Rebase hashes it with the configured algorithm, saves it, signs the user out of every existing session, and shows it to the admin to pass on. The hook does not store it, and has no way to. Return no temporaryPassword when the hook emails its own reset link instead: the password then stays as it is until the user sets a new one, though their sessions still end.
When custom hooks (onCreateUser, onResetPassword) are called, they receive an AuthCollectionContext facade containing:
hashPassword(password: string): Promise<string>— Hash password using the configured hashing algorithm (e.g. scrypt).sendEmail?: (options) => Promise<EmailSendResult>— Send an email (only available when email service is configured). Resolves with what the provider reported —messageId,accepted,rejected— so a hook can store the id and later thread a reply back to it.emailConfigured: boolean— Whether email service is configured.appName: string— The app name from email config.resetPasswordUrl: string— The password reset link base URL.
Next Steps
Section titled “Next Steps”- Endpoints and tokens — every route this configuration mounts
- Roles and scopes — what each role may do, and how keys and tokens narrow it
- Custom auth adapters — bringing your own identity provider
- Frontend Authentication — login UI, auth controller, user management
- Security Rules (RLS) — row-level access control
- Typed SDK Authentication — auth methods in the typed SDK