| name | rebase-auth |
| description | Guide for setting up and using Rebase Authentication, roles, Row-Level Security (RLS) policies, MFA, API keys, OAuth providers, custom auth adapters, and lifecycle hooks. Use this skill when the user needs to add authentication, manage users and roles, secure data access, configure OAuth, set up MFA, create API keys, or customize the auth pipeline. |
Rebase Authentication
Rebase ships a complete, built-in authentication system with JWT sessions, OAuth, MFA/TOTP, API keys, Row-Level Security, and lifecycle hooks — or you can plug in an external auth system (e.g., Clerk, Auth0, or custom identity providers) via the AuthAdapter interface.
IMPORTANT FOR AGENTS: Always read the rebase-basics skill first. The auth system is configured inside initializeRebaseBackend() which is covered there.
Table of Contents
Server-Side Configuration
Authentication is configured via the auth property of initializeRebaseBackend(). It accepts either a RebaseAuthConfig object (built-in auth) or an AuthAdapter (external auth).
Auth & multiple data sources. The built-in auth system (users, sessions, API keys) is bootstrapped on the default data source — the auth collection must live there (the backend warns at boot otherwise). RLS only protects Postgres: server collections on engines without row-level security (e.g. MongoDB) still require authentication but enforce authorization at the app layer (the backend warns for these). Direct data sources (e.g. Firestore) bypass Rebase auth entirely — they're governed by the external backend's own rules/token; use an AuthAdapter to unify identity. See the rebase-collections skill for the data-source model.
RebaseAuthConfig
| Property | Type | Default | Description |
|---|
collection | CollectionConfig | Built-in users collection | The collection used for auth user storage. Import defaultUsersCollection from @rebasepro/common or pass a custom collection with required auth fields. |
jwtSecret | string | — | Required. Secret for signing JWT access tokens. |
accessExpiresIn | string | "1h" | Access token lifetime (e.g. "15m", "2h"). |
refreshExpiresIn | string | "30d" | Refresh token lifetime. |
requireAuth | boolean | true | When true, data routes return 401 for unauthenticated requests. Set to false to rely entirely on Postgres RLS. |
allowRegistration | boolean | false | Enable self-service registration via POST /auth/register. |
allowUserLookup | boolean | false | Expose POST /auth/find-user — an authenticated email→minimal-profile lookup (uid/displayName/photoURL only) for invite flows. Enables user enumeration by signed-in users, so it's off by default. See Inviting by email. |
serviceKey | string | — | Static secret for server-to-server auth. Must be ≥ 32 characters. Requests with Authorization: Bearer <serviceKey> get admin access. |
defaultRole | string | — | Role ID assigned to new users (except the first user, who always gets "admin"). Must NOT be "admin" — throws a security error at startup. |
providers | OAuthProvider<unknown>[] | [] | Canonical OAuth provider array. Use create*Provider factories or pass custom providers. Named shorthand fields below are merged into this array at startup. |
hooks | AuthHooks | — | Lifecycle hooks to customize passwords, credentials, and auth events. |
email | EmailConfig | — | Email configuration for password resets, verification, and welcome emails. |
google | { clientId, clientSecret? } | — | Google OAuth shorthand. |
github | { clientId, clientSecret } | — | GitHub OAuth shorthand. |
microsoft | { clientId, clientSecret, tenantId? } | — | Microsoft/Entra ID shorthand. tenantId defaults to "common". |
apple | { clientId, teamId, keyId, privateKey } | — | Apple Sign In shorthand. privateKey is the raw PEM (.p8) contents. |
facebook | { clientId, clientSecret } | — | Facebook/Meta OAuth. |
twitter | { clientId, clientSecret } | — | Twitter/X OAuth 2.0 with PKCE. |
discord | { clientId, clientSecret } | — | Discord OAuth. |
gitlab | { clientId, clientSecret, baseUrl? } | — | GitLab OAuth. baseUrl defaults to "https://gitlab.com" (supports self-hosted). |
linkedin | { clientId, clientSecret } | — | LinkedIn OAuth (OIDC). |
bitbucket | { clientId, clientSecret } | — | Bitbucket OAuth. |
slack | { clientId, clientSecret } | — | Slack OAuth (OIDC). |
spotify | { clientId, clientSecret } | — | Spotify OAuth. |
Minimal Example
import { initializeRebaseBackend } from "@rebasepro/server";
import { createPostgresAdapter } from "@rebasepro/server-postgres";
await initializeRebaseBackend({
server,
app,
database: createPostgresAdapter({ connection: db, schema }),
auth: {
jwtSecret: process.env.JWT_SECRET!,
allowRegistration: true,
serviceKey: process.env.REBASE_SERVICE_KEY,
defaultRole: "member",
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
email: {
from: "noreply@myapp.com",
smtp: {
host: "smtp.resend.com",
port: 465,
secure: true,
auth: { user: "resend", pass: process.env.RESEND_API_KEY! },
},
appName: "MyApp",
resetPasswordUrl: "https://myapp.com",
verifyEmailUrl: "https://myapp.com",
},
},
});
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 { PostgresCollectionConfig } from "@rebasepro/types";
const membersCollection: PostgresCollectionConfig = {
name: "Members",
slug: "members",
table: "members",
auth: {
enabled: true,
onCreateUser: async (values, ctx) => {
const hash = await ctx.hashPassword("welcome123");
return {
values: { ...values, passwordHash: hash, emailVerified: true },
temporaryPassword: "welcome123"
};
},
onResetPassword: async (userId, ctx) => {
const tempPassword = "reset_" + Math.random().toString(36).substring(2, 8);
return {
temporaryPassword: tempPassword,
invitationSent: false
};
},
actions: {
resetPassword: true
}
},
properties: { ... }
};
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<void> — Send an email (only available when email service is configured).
emailConfigured: boolean — Whether email service is configured.
appName: string — The app name from email config.
resetPasswordUrl: string — The password reset link base URL.
First-User Bootstrap
IMPORTANT FOR AGENTS: The very first user registered (via POST /auth/register or OAuth) is automatically promoted to "admin". This prevents the chicken-and-egg problem. All subsequent users receive the defaultRole.
Inviting teammates by email
Invite flows must turn an email into a user id, but the users collection is
RLS-protected from the client. Do not hand-roll an admin server function for
this — enable allowUserLookup and use the built-in primitive:
const profile = await rebase.auth.findUserByEmail("teammate@example.com");
if (profile) {
await rebase.data.team_members.create({ team_id, user_id: profile.uid });
}
The find-user endpoint is authenticated-only and returns just the minimal
public profile. It is off by default because it enables user enumeration by any
signed-in user.
OAuth Providers
Rebase supports 12 built-in OAuth providers. Each provider is configured via a shorthand property on RebaseAuthConfig and automatically mounts a POST /api/auth/{providerId} endpoint.
Provider Reference
| Provider | ID | Config Properties | Client Payload |
|---|
| Google | google | clientId, clientSecret? | { idToken } OR { accessToken } OR { code, redirectUri } |
| GitHub | github | clientId, clientSecret | { code, redirectUri } |
| Microsoft | microsoft | clientId, clientSecret, tenantId? | { code, redirectUri } |
| Apple | apple | clientId, teamId, keyId, privateKey | { code, redirectUri, user? } |
| Facebook | facebook | clientId, clientSecret | { code, redirectUri } |
| Twitter/X | twitter | clientId, clientSecret | { code, redirectUri, codeVerifier } |
| Discord | discord | clientId, clientSecret | { code, redirectUri } |
| GitLab | gitlab | clientId, clientSecret, baseUrl? | { code, redirectUri } |
| LinkedIn | linkedin | clientId, clientSecret | { code, redirectUri } |
| Bitbucket | bitbucket | clientId, clientSecret | { code, redirectUri } |
| Slack | slack | clientId, clientSecret | { code, redirectUri } |
| Spotify | spotify | clientId, clientSecret | { code, redirectUri } |
Google Three-Path Support
Google is unique — it supports three verification paths:
- ID Token (One Tap / Sign In button) —
{ idToken }. Cryptographic verification via Google's public keys. No clientSecret needed.
- Access Token (popup via
initTokenClient) — { accessToken }. Validated via Google's userinfo endpoint. No clientSecret needed.
- Authorization Code (most secure) —
{ code, redirectUri }. Requires clientSecret. Tokens never touch the browser.
Apple Special Behavior
- Apple only sends the user's name on the first authorization. The frontend must capture and forward it:
{ code, redirectUri, user: { name: { firstName, lastName }, email } }.
- Apple does not provide a profile photo (
photoUrl is always null).
- The
privateKey is the raw PEM contents of the .p8 file downloaded from Apple Developer.
Twitter PKCE
Twitter uses OAuth 2.0 with PKCE. The client must send codeVerifier alongside code and redirectUri.
OAuth Account Linking
When an OAuth user signs in via POST /api/auth/{provider}:
- If an identity record exists for
(provider, providerId) → log in that user. The email is not consulted.
- If no identity exists but a user with the same email exists:
- The provider asserted
emailVerified: true → link the provider to the existing account and log in as that user. One account, two sign-in methods.
- The provider did NOT verify the email → reject with
403 EMAIL_NOT_VERIFIED. Nothing is created or modified.
- If neither exists → create a new user, link the identity, assign
defaultRole.
IMPORTANT FOR AGENTS: A second account is never silently created for
an email that already exists. If asked "does signing in with Google create a
duplicate user?", the answer is no — it either links (verified) or errors
(unverified). This is not configurable; there is deliberately no option to
auto-link on unverified emails, because that would let anyone who can make a
provider emit an address they don't own take over the matching account.
Google always asserts email_verified for real Google accounts, so linking
is the normal path for Google sign-in.
Linking a Provider to a Signed-In Account
POST /api/auth/link/{provider} attaches a provider identity to the already
authenticated account (requires Authorization: Bearer <token>). The body is
the same payload the provider's sign-in route takes, e.g. { idToken }.
This is the escape hatch from an EMAIL_NOT_VERIFIED rejection, and the way to
attach a provider whose email differs from the account's.
Unlike sign-in, linking here does not require a verified email and does not
require the emails to match — on sign-in the provider's email is the only
evidence tying the identity to an account, whereas here the caller has already
proven ownership by holding a valid session.
409 IDENTITY_ALREADY_LINKED if that provider identity belongs to another user.
- Idempotent (
alreadyLinked: true) if already linked to the caller.
Adding a Password to a Provider-Only Account
A user who signed up via OAuth has no passwordHash:
POST /auth/register with the same email → 409 EMAIL_EXISTS.
POST /auth/change-password → 400 INVALID_ACCOUNT (no existing password to verify).
forgot-password → reset-password is the supported path. It re-proves ownership of the address by email, after which the account has both sign-in methods.
Custom OAuth Provider
You can register any OAuth provider by implementing the OAuthProvider<T> interface:
import { z } from "zod";
import type { OAuthProvider, OAuthProviderProfile } from "@rebasepro/server";
const myProvider: OAuthProvider<{ token: string }> = {
id: "my-provider",
schema: z.object({ token: z.string().min(1) }),
verify: async (payload): Promise<OAuthProviderProfile | null> => {
const userInfo = await verifyExternalToken(payload.token);
if (!userInfo) return null;
return {
providerId: userInfo.id,
email: userInfo.email,
displayName: userInfo.name || null,
photoUrl: userInfo.avatar || null,
};
},
};
auth: {
jwtSecret: "...",
providers: [myProvider],
}
Auth Lifecycle Hooks
The AuthHooks interface lets you customize specific behaviors of the built-in auth system. Every hook is optional — unset hooks fall through to built-in defaults.
Hook Reference
| Hook | Signature | Default | Behavior |
|---|
hashPassword | (password: string) => Promise<string> | scrypt (Node crypto, 64-byte key, 32-byte salt) | Hash a cleartext password for storage. |
verifyPassword | (password: string, storedHash: string) => Promise<boolean> | scrypt with timing-safe comparison | Verify cleartext password against stored hash. |
validatePasswordStrength | (password: string) => PasswordValidationResult | Min 8 chars, 1 uppercase, 1 lowercase, 1 digit | Return { valid: boolean, errors: string[] }. |
verifyCredentials | (email, password, repo: AuthRepository) => Promise<UserData | null> | getUserByEmail + verifyPassword | Override the entire login credential check. Return user or null. |
onAuthenticated | (user: UserData, method: AuthMethod) => Promise<void> | — | Called after any successful auth event. Fire-and-forget. |
beforeUserCreate | (data: CreateUserData) => Promise<CreateUserData> | Passthrough | Modify or reject user creation. Throw to abort. |
afterUserCreate | (user: UserData) => Promise<void> | — | Called after user creation. Fire-and-forget. |
beforeLogin | (email: string, method: AuthMethod) => Promise<void> | — | Pre-login validation. Throw to reject (e.g. account lockout). |
afterLogout | (userId: string) => Promise<void> | — | Post-logout cleanup. Fire-and-forget. |
onMfaVerified | (userId: string, factorId: string) => Promise<void> | — | Called after successful MFA verification. Fire-and-forget. |
customizeAccessToken | (claims: Record<string, unknown>, user: UserData) => Promise<Record<string, unknown>> | — | Modify JWT access token claims before signing. |
transformAuthResponse | (response: AuthResponsePayload, context: TransformAuthResponseContext) => Promise<AuthResponsePayload> | — | Transform the auth response before sending to client. Runs in-request (not fire-and-forget). Errors are caught and logged; untransformed response returned as fallback. |
onPasswordReset | (userId: string) => Promise<void> | — | Called after successful password reset. Fire-and-forget. |
beforeUserDelete | (userId: string) => Promise<void> | — | Throw to prevent deletion. |
afterUserDelete | (userId: string) => Promise<void> | — | Post-deletion cleanup. Fire-and-forget. |
AuthMethod Values
"login" | "register" | "oauth" | "refresh" | "password-reset" | "anonymous" | "magic-link" | "mfa"
AuthResponsePayload
interface AuthResponsePayload {
user?: {
uid: string;
email: string;
displayName: string | null;
photoURL: string | null;
roles: string[];
metadata: Record<string, unknown>;
};
tokens: {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
[key: string]: unknown;
};
}
TransformAuthResponseContext
interface TransformAuthResponseContext {
userId: string;
method: "login" | "register" | "oauth" | "refresh" | "anonymous" | "magic-link" | "mfa";
request: Request;
}
PasswordValidationResult
interface PasswordValidationResult {
valid: boolean;
errors: string[];
}
Example: bcrypt Passwords
import bcrypt from "bcrypt";
auth: {
jwtSecret: "...",
hooks: {
hashPassword: (pw) => bcrypt.hash(pw, 12),
verifyPassword: (pw, hash) => bcrypt.compare(pw, hash),
validatePasswordStrength: (pw) => ({
valid: pw.length >= 6,
errors: pw.length < 6 ? ["Password must be at least 6 characters"] : [],
}),
},
}
Example: Custom JWT Claims
hooks: {
customizeAccessToken: async (claims, user) => ({
...claims,
org_id: user.metadata?.organizationId,
plan: user.metadata?.plan || "free",
}),
}
Example: Audit Logging
hooks: {
onAuthenticated: async (user, method) => {
await auditLog.write({
event: "auth.success",
userId: user.id,
method,
timestamp: new Date(),
});
},
beforeLogin: async (email, method) => {
const isBlocked = await checkAccountLockout(email);
if (isBlocked) throw new Error("Account is locked");
},
}
Example: External Token Bridge (e.g. custom auth system)
import admin from "firebase-admin";
hooks: {
transformAuthResponse: async (response, context) => {
const firebaseToken = await admin.auth().createCustomToken(context.userId);
return {
...response,
tokens: {
...response.tokens,
firebaseToken,
},
};
},
}
The frontend can then call signInWithCustomToken(providerToken) immediately after login.
MFA / TOTP
Rebase supports Multi-Factor Authentication via TOTP (Time-based One-Time Password). The flow uses an enrollment → verify → challenge pattern with recovery codes.
MFA Flow
- Enroll —
POST /api/auth/mfa/enroll returns a TOTP secret, URI (for QR), and 10 recovery codes.
- Verify enrollment —
POST /api/auth/mfa/verify with a 6-digit TOTP code to confirm the factor.
- Challenge on login — After normal login (aal1), call
POST /api/auth/mfa/challenge to create a challenge.
- Complete challenge —
POST /api/auth/mfa/challenge/verify with TOTP or recovery code. Upgrades token from aal1 → aal2.
MFA Endpoints
| Method | Endpoint | Auth | Description |
|---|
POST | /api/auth/mfa/enroll | Required | Start enrollment. Returns { factor, totp: { secret, uri, qrUri }, recoveryCodes }. |
POST | /api/auth/mfa/verify | Required | Verify enrollment with { factorId, code } (6-digit TOTP). |
POST | /api/auth/mfa/challenge | Required | Create challenge with { factorId }. Returns { challengeId, factorId, expiresAt }. Challenge expires in 5 minutes. |
POST | /api/auth/mfa/challenge/verify | Required | Complete challenge with { challengeId, code }. Returns new tokens with aal2. Accepts TOTP (6 digits) or recovery code (>6 chars). |
GET | /api/auth/mfa/factors | Required | List enrolled factors: { factors: [{ id, factorType, friendlyName, verified, createdAt }] }. |
DELETE | /api/auth/mfa/unenroll | Required | Remove factor with { factorId } in body. Auto-cleans recovery codes when no verified factors remain. |
MFA Types
interface MfaFactor {
id: string;
userId: string;
factorType: "totp";
friendlyName?: string;
verified: boolean;
createdAt: Date;
updatedAt: Date;
}
interface MfaChallengeInfo {
id: string;
factorId: string;
createdAt: Date;
verifiedAt?: Date;
ipAddress?: string;
}
AAL (Authentication Assurance Levels)
| Level | Meaning |
|---|
aal1 | Standard authentication (email/password, OAuth). |
aal2 | Elevated after MFA challenge verification. |
API Keys
API keys provide machine-to-machine authentication for agents, MCP servers, CI pipelines, cron jobs, and third-party integrations. They are scoped to specific collections and operations, and can optionally be granted full admin access.
Key Format
- Prefix:
rk_ (e.g. rk_live_abc123...)
- Storage: SHA-256 hash of the full key. The plaintext key is returned exactly once at creation.
- Display: Only the first 12 characters (
key_prefix) are shown in subsequent API responses.
Admin Access for Agents / MCP
By default API keys get the service role (data access only). Set "admin": true to grant the key the admin role, which allows it to call all admin routes (/api/admin/*) — including schema management, user management, and API key management itself.
Use admin: true for agents, MCP servers, and CI pipelines that need full control over the Rebase instance.
rebase api-keys create --name "My Agent" --admin --full-access
curl -X POST http://localhost:3000/api/admin/api-keys \
-H "Authorization: Bearer <service-key>" \
-H "Content-Type: application/json" \
-d '{
"name": "My Agent",
"admin": true,
"permissions": [{ "collection": "*", "operations": ["read", "write", "delete"] }]
}'
API Key Admin Endpoints
All endpoints are mounted under /api/admin/api-keys and require admin authentication (JWT with admin role or service key).
| Method | Endpoint | Description |
|---|
GET | /api/admin/api-keys | List all API keys (masked — no hashes). |
POST | /api/admin/api-keys | Create a new API key. Returns the full plaintext key once. |
GET | /api/admin/api-keys/:id | Get single API key details (masked). |
PUT | /api/admin/api-keys/:id | Update name, permissions, admin, rate_limit, or expires_at. |
DELETE | /api/admin/api-keys/:id | Revoke (soft-delete) an API key. |
Create API Key Request
interface CreateApiKeyRequest {
name: string;
permissions: ApiKeyPermission[];
admin?: boolean;
rate_limit?: number | null;
expires_at?: string | null;
}
interface ApiKeyPermission {
collection: string;
operations: ("read" | "write" | "delete")[];
}
Examples
Scoped key (read-only on one collection):
curl -X POST http://localhost:3000/api/admin/api-keys \
-H "Authorization: Bearer <admin-token-or-service-key>" \
-H "Content-Type: application/json" \
-d '{
"name": "Analytics Pipeline",
"permissions": [
{ "collection": "events", "operations": ["read", "write"] },
{ "collection": "users", "operations": ["read"] }
],
"rate_limit": 500,
"expires_at": "2025-12-31T23:59:59Z"
}'
Admin key (for agents / MCP / CI):
curl -X POST http://localhost:3000/api/admin/api-keys \
-H "Authorization: Bearer <admin-token-or-service-key>" \
-H "Content-Type: application/json" \
-d '{
"name": "CI Agent",
"admin": true,
"permissions": [{ "collection": "*", "operations": ["read", "write", "delete"] }]
}'
Using an API Key
curl http://localhost:3000/api/data/events \
-H "Authorization: Bearer rk_live_abc123..."
API Key Middleware Behavior
When a request arrives with a rk_ prefixed bearer token:
- The token is SHA-256 hashed and looked up in the
rebase.api_keys table.
- Expiry and revocation status are checked.
- If
admin: true, the key is assigned roles: ["admin", "service"] — granting access to admin routes. Otherwise roles: ["service"].
- Permissions are validated against the requested collection and HTTP method (
GET → read, POST/PUT/PATCH → write, DELETE → delete).
- The DataDriver is scoped with
withAuth() using the key's service identity (bypasses RLS).
- Per-key rate limiting is enforced if
rate_limit is set.
WARNING FOR AGENTS: API keys bypass RLS. They are designed for trusted server-side use only. Never expose API keys to client-side code.
Role Summary
| Key type | roles assigned | Admin routes | Data routes |
|---|
Default (no admin) | ["service"] | ✗ | ✓ (scoped by permissions) |
admin: true | ["admin", "service"] | ✓ | ✓ |
API Key Response Types
interface ApiKeyWithSecret {
id: string;
name: string;
key_prefix: string;
key: string;
permissions: ApiKeyPermission[];
admin: boolean;
rate_limit: number | null;
created_by: string;
created_at: string;
updated_at: string;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
}
interface ApiKeyMasked {
id: string;
name: string;
key_prefix: string;
permissions: ApiKeyPermission[];
admin: boolean;
rate_limit: number | null;
created_by: string;
created_at: string;
updated_at: string;
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
}
Also update admin on an existing key
curl -X PUT http://localhost:3000/api/admin/api-keys/<id> \
-H "Authorization: Bearer <admin-token-or-service-key>" \
-H "Content-Type: application/json" \
-d '{ "admin": true }'
CLI
rebase api-keys list
rebase api-keys create --name "Read Only" --permissions '[{"collection":"orders","operations":["read"]}]'
rebase api-keys create --name "My Agent" --admin --full-access
rebase api-keys revoke <key-id>
REST Endpoints
All auth endpoints are mounted under /api/auth. Admin endpoints are under /api/admin.
Public Auth Endpoints
| Method | Endpoint | Rate Limit | Auth | Description |
|---|
POST | /auth/register | default (200/15min) | No | Create account. Body: { email, password, displayName? }. |
POST | /auth/login | default | No | Email/password login. Body: { email, password }. |
POST | /auth/{providerId} | default | No | OAuth sign-in. Body varies by provider. |
POST | /auth/refresh | — | No | Refresh access token. Body: { refreshToken }. Rotates refresh token. |
POST | /auth/logout | — | No | Invalidate refresh token. Body: { refreshToken? }. |
POST | /auth/anonymous | strict (50/15min) | No | Create anonymous user with temp credentials. |
POST | /auth/forgot-password | strict | No | Request password reset email. Body: { email }. Always returns success (security). |
POST | /auth/reset-password | strict | No | Reset password with token. Body: { token, password }. Invalidates all sessions. |
GET | /auth/verify-email | — | No | Verify email. Query: ?token=<token>. |
GET | /auth/config | default | No | Get auth capabilities for frontend: { needsSetup, registrationEnabled, emailServiceEnabled, enabledProviders }. |
Authenticated Endpoints
| Method | Endpoint | Auth | Description |
|---|
GET | /auth/me | Required | Get current user profile + roles. |
PATCH | /auth/me | Required | Update profile. Body: { displayName?, photoURL? }. |
POST | /auth/change-password | Required | Change password. Body: { oldPassword, newPassword }. Invalidates all sessions. |
POST | /auth/send-verification | Required | Send email verification link. Requires email service. |
POST | /auth/link/{provider} | Required | Link an OAuth provider to the current account. Body: the provider's sign-in payload (e.g. { idToken }). 409 IDENTITY_ALREADY_LINKED if it belongs to another user. |
GET | /auth/sessions | Required | List active sessions (refresh tokens). |
DELETE | /auth/sessions | Required | Revoke all sessions (remote logout). |
DELETE | /auth/sessions/:id | Required | Revoke a specific session. |
POST | /auth/anonymous/link | Required | Upgrade anonymous → permanent. Body: { email, password }. |
Auth Response Format
All login/register/OAuth endpoints return:
{
"user": {
"uid": "uuid",
"email": "user@example.com",
"displayName": "John",
"photoURL": null,
"roles": ["member"],
"metadata": {}
},
"tokens": {
"accessToken": "eyJ...",
"refreshToken": "hex-string",
"accessTokenExpiresAt": 1700000000000
}
}
TIP: Use the transformAuthResponse hook to inject additional tokens (e.g., external system tokens) or metadata into this response. See Auth Lifecycle Hooks.
Error Response Format
{
"error": {
"message": "Invalid email or password",
"code": "INVALID_CREDENTIALS"
}
}
Common Error Codes
| Code | HTTP | Description |
|---|
INVALID_CREDENTIALS | 401 | Wrong email/password. |
INVALID_TOKEN | 401 | Invalid or expired refresh/reset token. |
TOKEN_EXPIRED | 401 | Refresh token has expired. |
REGISTRATION_DISABLED | 403 | allowRegistration is false. |
EMAIL_EXISTS | 409 | Email already registered. |
WEAK_PASSWORD | 400 | Password fails strength validation. |
INVALID_INPUT | 400 | Zod validation failure. |
EMAIL_NOT_CONFIGURED | 503 | Email service not set up (password reset/verification unavailable). |
ALREADY_VERIFIED | 400 | Email already verified. |
NOT_ANONYMOUS | 400 | User is not anonymous (cannot link). |
RATE_LIMITED | 429 | Too many requests. |
Client SDK
The client SDK's auth module is created via createAuth(transport, options?). It manages tokens, auto-refresh, session persistence, and state change listeners.
CreateAuthOptions
| Option | Type | Default | Description |
|---|
storage | AuthStorage | localStorage (browser) or in-memory | Token persistence backend. |
authPath | string | "/auth" | Base path for auth endpoints. |
autoRefresh | boolean | true | Auto-refresh access tokens 2 minutes before expiry. |
persistSession | boolean | true | Persist session to storage between page loads. |
Client SDK Methods
const { auth } = createRebaseClient({ baseUrl: "http://localhost:3000" });
await auth.signInWithEmail(email, password);
await auth.signUp(email, password, displayName?);
await auth.signInWithGoogle({ idToken });
await auth.signInWithGoogle({ accessToken });
await auth.signInWithGoogle({ code, redirectUri });
await auth.signInWithGitHub(code, redirectUri);
await auth.signInWithMicrosoft(code, redirectUri);
await auth.signInWithApple(code, redirectUri, user?);
await auth.signInWithFacebook(code, redirectUri);
await auth.signInWithTwitter(code, redirectUri, codeVerifier);
await auth.signInWithDiscord(code, redirectUri);
await auth.signInWithGitLab(code, redirectUri);
await auth.signInWithLinkedin(code, redirectUri);
await auth.signInWithBitbucket(code, redirectUri);
await auth.signInWithSlack(code, redirectUri);
await auth.signInWithSpotify(code, redirectUri);
await auth.signInWithOAuth(providerId, payload);
await auth.signOut();
await auth.refreshSession();
auth.getSession();
await auth.getUser();
await auth.updateUser({ displayName?, photoURL? });
await auth.resetPasswordForEmail(email);
await auth.resetPassword(token, newPassword);
await auth.changePassword(oldPassword, newPassword);
await auth.sendVerificationEmail();
await auth.verifyEmail(token);
await auth.getSessions();
await auth.revokeSession(sessionId);
await auth.revokeAllSessions();
await auth.getAuthConfig();
const unsubscribe = auth.onAuthStateChange((event, session) => {
console.log(event, session?.user);
});
Client Types
interface RebaseUser {
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
emailVerified?: boolean;
roles?: string[];
providerId: string;
isAnonymous: boolean;
}
interface RebaseSession {
accessToken: string;
refreshToken: string;
expiresAt: number;
user: RebaseUser;
}
type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
Custom Storage Backends
import { createMemoryStorage, createCookieStorage } from "@rebasepro/client";
const auth = createAuth(transport, {
storage: createMemoryStorage(),
});
const auth = createAuth(transport, {
storage: createCookieStorage({
path: "/",
sameSite: "Lax",
secure: true,
domain: ".myapp.com",
maxAge: 365 * 24 * 60 * 60,
}),
});
Session Restoration
On initialization (when persistSession is true):
- Load stored session from storage.
- If access token is still valid → restore session and schedule refresh.
- If access token is expired but refresh token exists → immediately attempt refresh.
- If refresh fails → clear session and emit
SIGNED_OUT.
Row-Level Security
Rebase implements RLS by scoping the DataDriver via withAuth() before each request. This injects the authenticated user's identity into the database context.
How RLS Scoping Works
- Auth middleware verifies the JWT (or API key / service key).
- The middleware calls
scopeDataDriver(driver, { uid, roles }).
- If the driver supports
withAuth() (e.g. Postgres), it returns a scoped clone with Postgres session variables set:
auth.uid() — the user's ID
auth.jwt() — the JWT claims
auth.roles() — the user's role IDs
- All subsequent queries in that request use the scoped driver with RLS policies applied.
Fail-Closed Security
IMPORTANT FOR AGENTS: If withAuth() throws an error, the request is rejected with 500. The system never falls back to unscoped access. This is by design (fail-closed).
Anonymous Users
When requireAuth is false and no token is provided, the driver is scoped with:
uid: "anon"
roles: ["anon"]
This allows Postgres RLS policies to handle public access explicitly.
Service Key Scoping
Requests with the serviceKey bypass RLS — they receive admin-level access with uid: "service" and roles: ["admin"].
API Key Scoping
API keys use a service identity for RLS scoping: uid: "api-key:{id}", roles: ["service"] (or ["admin", "service"] when admin: true). They do not inherit the created_by user's identity.
Reserved System Identities
The auth middleware assigns these reserved identities automatically. They are visible in context.user (Collection Callbacks / DataHooks) and c.get("user") (custom functions):
| Auth Method | userId | roles | When It Occurs |
|---|
| JWT (end-user) | Real user ID (e.g. "abc123") | User's assigned roles (e.g. ["viewer"]) | Normal authenticated requests |
| Service Key | "service" | ["admin"] | Server-side rebase.data calls, cron jobs, or any request with Authorization: Bearer <serviceKey> |
| API Key (default) | "api-key:{id}" | ["service"] | Machine-to-machine API key requests |
| API Key (admin) | "api-key:{id}" | ["admin", "service"] | Admin API key requests |
| Anonymous | "anon" | ["anon"] | Unauthenticated when requireAuth: false |
No token + requireAuth: true | — | — | Rejected (401) |
IMPORTANT FOR AGENTS: Server-side rebase.data (the singleton used in cron jobs, custom functions, and webhooks) is built with createRebaseClient({ token: serviceKey }). It round-trips through the REST API, so all middleware, DataHooks, and Collection Callbacks fire with userId: "service", roles: ["admin"]. This lets developers distinguish server-internal reads from end-user reads in callbacks.
Rate Limiting
Rebase uses an in-memory sliding-window rate limiter with IP-based keying.
Pre-configured Limiters
| Limiter | Window | Limit | Applied To |
|---|
defaultAuthLimiter | 15 minutes | 200 requests | /auth/register, /auth/login, /auth/{provider}, /auth/config |
strictAuthLimiter | 15 minutes | 50 requests | /auth/forgot-password, /auth/reset-password, /auth/anonymous |
Rate Limit Response Headers
All rate-limited endpoints include:
| Header | Description |
|---|
X-RateLimit-Limit | Maximum requests in the window. |
X-RateLimit-Remaining | Remaining requests in current window. |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets. |
Retry-After | Seconds until the client can retry (only on 429). |
API Key Rate Limiting
API keys have their own per-key rate limiter. The rate_limit on each key specifies requests per 15-minute window. When rate_limit is null, a default of 1000 requests per 15 minutes is applied.
Rate Limit Error Response
{
"error": {
"message": "Too many requests, please try again later.",
"code": "RATE_LIMITED"
}
}
Rate limiting
Configure it on the backend, with rateLimit. createRateLimiter and friends are
internal plumbing — @rebasepro/server deliberately does not republish them at the
package root, because the limits a backend author actually wants are these:
const backend = await initializeRebaseBackend({
rateLimit: {
windowMs: 60 * 1000,
user: 1000,
apiKey: 1000,
anonymous: 100,
}
});
Custom Auth Adapters
For external auth systems (Clerk, Auth0, custom providers, or custom JWT), use the AuthAdapter interface or the createCustomAuthAdapter() helper.
AuthAdapter Interface
interface AuthAdapter {
readonly id: string;
verifyRequest(request: Request): Promise<AuthenticatedUser | null>;
verifyToken?(token: string): Promise<AuthenticatedUser | null>;
userManagement?: UserManagementAdapter;
createAuthRoutes?(): Hono<any> | undefined;
createAdminRoutes?(): Hono<any> | undefined;
getCapabilities(): AuthAdapterCapabilities | Promise<AuthAdapterCapabilities>;
initialize?(): Promise<void>;
destroy?(): Promise<void>;
serviceKey?: string;
transformAuthResponse?(response: AuthResponsePayload, context: TransformAuthResponseContext): Promise<AuthResponsePayload>;
}
interface AuthenticatedUser {
uid: string;
email: string;
displayName?: string | null;
photoUrl?: string | null;
roles: string[];
isAdmin: boolean;
rawToken?: string;
claims?: Record<string, unknown>;
}
createCustomAuthAdapter
The simplest way to plug an existing auth system into Rebase. Only verifyRequest is required:
import { createCustomAuthAdapter } from "@rebasepro/server";
import jwt from "jsonwebtoken";
const auth = createCustomAuthAdapter({
verifyRequest: async (request) => {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) return null;
try {
const decoded = jwt.verify(token, MY_SECRET) as any;
return {
uid: decoded.sub,
email: decoded.email,
displayName: decoded.name,
roles: decoded.roles || [],
isAdmin: decoded.roles?.includes("admin") ?? false,
};
} catch {
return null;
}
},
verifyToken: async (token) => {
},
userManagement: { ... },
serviceKey: process.env.REBASE_SERVICE_KEY,
capabilities: {
emailPasswordLogin: false,
registration: false,
enabledProviders: ["google"],
},
transformAuthResponse: async (response, context) => {
const externalToken = await generateExternalToken(context.userId);
return {
...response,
tokens: { ...response.tokens, externalToken },
};
},
});
await initializeRebaseBackend({
server, app,
database: createPostgresAdapter({ connection: db, schema }),
auth,
});
AuthAdapterCapabilities
The frontend reads these from GET /api/auth/config to dynamically show/hide UI:
interface AuthAdapterCapabilities {
hasBuiltInAuthRoutes: boolean;
emailPasswordLogin: boolean;
registration: boolean;
passwordReset: boolean;
sessionManagement: boolean;
profileUpdate: boolean;
emailVerification: boolean;
enabledProviders: string[];
externalLoginUrl?: string;
needsSetup?: boolean;
registrationEnabled?: boolean;
}
Default Capabilities for Custom Adapters
When using createCustomAuthAdapter, all capabilities default to false/[] unless overridden via capabilities.
Roles & Permissions
Role Data Structure
interface RoleData {
id: string;
name: string;
isAdmin: boolean;
defaultPermissions: {
read?: boolean;
create?: boolean;
edit?: boolean;
delete?: boolean;
} | null;
collectionPermissions: Record<string, {
read?: boolean;
create?: boolean;
edit?: boolean;
delete?: boolean;
}> | null;
}
Built-in Role Behavior
- The first user in the system is automatically assigned the
"admin" role.
- Subsequent users get the
defaultRole (if configured).
- Setting
defaultRole: "admin" throws a startup error to prevent privilege escalation.
- Admin status is determined by having a role with
id === "admin" or id === "schema-admin".
Admin Routes for User/Role Management
Admin user and role management is handled via dedicated admin routes (mounted under /api/admin) which require requireAuth + requireAdmin middleware.
Backend Hooks
Backend hooks intercept data at the API boundary (after DB operations, before API responses). They are separate from auth hooks and collection-level CollectionCallbacks.
BackendHooks Interface
interface BackendHooks {
users?: UserHooks;
data?: DataHooks;
}
UserHooks (Admin User Management)
| Hook | Signature | Description |
|---|
afterRead | (user, context) => AdminUser | null | Transform user after DB read. Return null to hide. |
beforeSave | (data, context) => data | Transform before write. Throw to abort. |
afterSave | (user, context) => void | After user create/update. Side effects. |
beforeDelete | (userId, context) => void | Throw to prevent deletion. |
afterDelete | (userId, context) => void | After user deleted. |
DataHooks (All Collection Entities)
| Hook | Signature | Description |
|---|
afterRead | (slug, entity, context) => entity | null | Transform entity after read. Return null to filter out. |
beforeSave | (slug, values, entityId, context) => values | Transform before write. Throw to abort. |
afterSave | (slug, entity, context) => void | After entity create/update. Side effects. |
beforeDelete | (slug, entityId, context) => void | Throw to prevent deletion. |
afterDelete | (slug, entityId, context) => void | After entity deleted. |
BackendHookContext
interface BackendHookContext {
requestUser?: { userId: string; roles: string[] };
method: "GET" | "POST" | "PUT" | "DELETE";
}
Example: PII Masking
const hooks: BackendHooks = {
data: {
afterRead(slug, entity, ctx) {
if (!ctx.requestUser?.roles.includes("admin") && entity.email) {
return { ...entity, email: "***" };
}
return entity;
},
},
users: {
afterRead(user, ctx) {
if (user.email.endsWith("@system.internal")) return null;
return user;
},
},
};
await initializeRebaseBackend({
hooks,
});
Email Configuration
Email is required for password reset, email verification, and welcome emails. Configure via auth.email.
EmailConfig
| Property | Type | Required | Description |
|---|
from | string | Yes | Sender address (e.g. "MyApp <noreply@myapp.com>"). |
smtp | SMTPConfig | One of smtp or sendEmail | SMTP server configuration. |
sendEmail | (options) => Promise<void> | One of smtp or sendEmail | Custom email sending function (e.g. AWS SES, Resend SDK). |
resetPasswordUrl | string | No | Base URL for reset links: {url}/reset-password?token=xxx. |
verifyEmailUrl | string | No | Base URL for verification links: {url}/verify-email?token=xxx. |
appName | string | No | App name in email templates. Defaults to "Rebase". |
templates | Object | No | Custom template functions (see below). |
SMTPConfig
interface SMTPConfig {
host: string;
port: number;
secure?: boolean;
auth?: { user: string; pass: string };
name?: string;
}
Custom Email Templates
email: {
from: "noreply@myapp.com",
smtp: { host: "smtp.example.com", port: 587 },
templates: {
passwordReset: (resetUrl, user) => ({
subject: "Reset your password",
html: `<p>Hi ${user.displayName || user.email},</p><p><a href="${resetUrl}">Reset</a></p>`,
text: `Reset your password: ${resetUrl}`,
}),
emailVerification: (verifyUrl, user) => ({
subject: "Verify your email",
html: `<a href="${verifyUrl}">Verify</a>`,
}),
welcomeEmail: (user, appName) => ({
subject: `Welcome to ${appName}!`,
html: `<p>Welcome, ${user.displayName || user.email}!</p>`,
}),
userInvitation: (setPasswordUrl, user) => ({
subject: "You've been invited",
html: `<p>Set your password: <a href="${setPasswordUrl}">here</a></p>`,
}),
},
}
Custom Email Provider (Non-SMTP)
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
email: {
from: "noreply@myapp.com",
sendEmail: async (options) => {
await resend.emails.send({
from: options.from || "noreply@myapp.com",
to: options.to,
subject: options.subject,
html: options.html,
text: options.text,
});
},
appName: "MyApp",
resetPasswordUrl: "https://myapp.com",
}
Security Concepts
Service Key
A static secret for server-to-server authentication. Generate with:
node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
When a request includes Authorization: Bearer <serviceKey>:
- It bypasses JWT verification.
- It receives admin-level access (
uid: "service", roles: ["admin"]).
- Comparison is done with constant-time comparison to prevent timing attacks.
- Must be ≥ 32 characters (validated at startup).
TIP: In Collection Callbacks and DataHooks, server-side rebase.data calls appear as userId: "service", roles: ["admin"]. Use this to skip masking, bypass rate limits, or grant elevated access in your callback logic.
Token Rotation
Refresh tokens are rotated on every use:
- Client sends refresh token to
POST /auth/refresh.
- Server deletes the old refresh token and creates a new one.
- New access + refresh tokens are returned.
Password Reset Security
POST /auth/forgot-password always returns success (doesn't reveal whether email exists).
- Reset tokens are stored as SHA-256 hashes.
- Tokens expire in 1 hour.
- After password reset, all sessions are invalidated (all refresh tokens deleted).
Zod Input Validation
All auth endpoints validate input with Zod schemas:
| Field | Validation |
|---|
email | Valid email, max 255 chars |
password | Min 1 char, max 128 chars |
displayName | Max 255 chars |
photoURL | Valid URL, max 2048 chars |
refreshToken | Min 1 char |
References
- Source:
packages/server/src/auth/ — All auth implementation
- Source:
packages/server/src/auth/routes.ts — REST auth endpoints
- Source:
packages/server/src/auth/auth-hooks.ts — Lifecycle hooks
- Source:
packages/server/src/auth/api-keys/ — API key system
- Source:
packages/server/src/auth/rate-limiter.ts — Rate limiting
- Source:
packages/server/src/init.ts — RebaseAuthConfig and backend init
- Source:
packages/client/src/auth.ts — Client SDK auth module
- Source:
packages/types/src/types/auth_adapter.ts — AuthAdapter interface
- Source:
packages/server/src/auth/rls-scope.ts — RLS scoping
- Source:
packages/server/src/email/types.ts — Email configuration
- Reserved Identities:
"service" / "anon" / "api-key:{id}" — see Row-Level Security > Reserved System Identities