Skip to content

Authentication

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 — First user automatically gets the admin role

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
},
appName: env.APP_NAME,
resetPasswordUrl: env.FRONTEND_URL, // URL for password reset page
}
: undefined,
// Lifecycle hooks
hooks: {
afterUserCreate: async (user) => {
console.log(`New user registered: ${user.email}`);
}
}
}
});

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: "..." },
}

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. It never silently creates a second account for the same address.

On POST /api/auth/<provider> the resolution order is:

  1. Known provider identity — if this exact provider identity has signed in before, that user is returned. The email is not consulted.
  2. Existing account with the same email, provider verified it — the identity is attached to the existing account and the user is signed in to it. One account, two ways in.
  3. Existing account with the same email, provider did NOT verify it — rejected with 403 EMAIL_NOT_VERIFIED. Nothing is created or modified.
  4. No account with that email — a new account is created.

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.

This behavior is not configurable — there is deliberately no option to link on unverified emails.

To recover from a step-3 rejection, the user signs in with their existing method and calls the explicit link endpoint:

POST /api/auth/link/google
Authorization: Bearer <access token>
{ "idToken": "..." }

Linking while authenticated intentionally does not require a verified email, and does not require the emails to match at all — a user’s Google address is often not their app address. The asymmetry is deliberate: on sign-in the provider’s email is the only evidence tying the incoming identity to an account, whereas here the caller has already proven ownership by holding a valid session. It returns 409 IDENTITY_ALREADY_LINKED if that provider identity belongs to another user, and is idempotent if it is already linked to the caller.

A user who signed up with Google and has no password:

  • Registering with the same email is refused with 409 EMAIL_EXISTS.
  • POST /api/auth/change-password returns 400 INVALID_ACCOUNT — there is no existing password to verify against.
  • forgot-passwordreset-password is the supported way to add one. It re-proves ownership of the address by email, after which the account has both sign-in methods.

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

All data API endpoints require a valid Authorization: Bearer <token> header when requireAuth: true (the default).

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 rebase.auth.findUserByEmail("teammate@example.com");
// → { uid, displayName, photoURL } | null (never email/roles/metadata)
if (profile) {
await rebase.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.

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 a roles text[] 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 on token_hash and a unique constraint on (user_id, user_agent, ip_address) to track active device sessions.
  • 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.

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:

  • auth.uid() — Returns the authenticated user’s ID as text, or NULL if not set:
    CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
    SELECT NULLIF(current_setting('app.user_id', true), '');
    $$ LANGUAGE sql STABLE;
  • auth.roles() — Returns the comma-separated roles string:
    CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
    SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
    $$ LANGUAGE sql STABLE;
  • auth.jwt() — Returns the full JWT payload as a jsonb object:
    CREATE OR REPLACE FUNCTION auth.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 = auth.uid() OR string_to_array(auth.roles(), ',') && ARRAY['admin']);

When no users exist in the database, the first person to register automatically becomes an admin. After that, registration is controlled by the allowRegistration setting.

This ensures you can always bootstrap a fresh deployment without needing to seed the database 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'));

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 { defineCollection } from "@rebasepro/admin-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 = "reset_" + Math.random().toString(36).substring(2, 8);
return {
temporaryPassword: tempPassword,
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
}
},
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.

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.

Rebase allows complete replacement of the built-in authentication system via a pluggable authentication architecture. This decouples authentication verification from the database and REST/WebSocket layers, enabling seamless integration with external providers such as Clerk, Auth0, Firebase Auth, or custom JWT identity services.

You can implement the AuthAdapter interface directly for complete control. The interface definition is as follows:

import { Hono } from "hono";
import type { HonoEnv } from "@rebasepro/server";
import { AuthenticatedUser, AuthAdapterCapabilities, UserManagementAdapter, UserCreationPrepareResult, UserCreationFinalizeResult } from "@rebasepro/types";
export interface AuthAdapter {
/** Unique identifier for this auth adapter (e.g., "clerk", "custom") */
readonly id: string;
/**
* Verifies an incoming HTTP request and returns the authenticated user payload.
* Called by Hono authentication middleware on every REST endpoint.
*/
verifyRequest(request: Request): Promise<AuthenticatedUser | null>;
/**
* Verifies a raw token string (e.g. for WebSocket connection handshake phase 1).
* If omitted, a synthetic request is automatically constructed.
*/
verifyToken?(token: string): Promise<AuthenticatedUser | null>;
/** Optional user management operations (CRUD) for the Admin Dashboard panel */
userManagement?: UserManagementAdapter;
/** Optional: Mount adapter-specific custom public routes (e.g. callback paths) */
createAuthRoutes?(): Hono<any, any, any> | undefined;
/** Optional: Mount adapter-specific admin-only routes */
createAdminRoutes?(): Hono<any, any, any> | undefined;
/** Advertise supported capabilities (to customize Admin Dashboard UI visibility) */
getCapabilities(): AuthAdapterCapabilities | Promise<AuthAdapterCapabilities>;
/** Lifecycle hooks called during backend start and graceful shutdown */
initialize?(): Promise<void>;
destroy?(): Promise<void>;
/** Custom user lifecycle hooks (e.g., hash passwords before collection writes) */
prepareUserCreation?(
values: Record<string, unknown>,
collectionAuth?: unknown
): Promise<UserCreationPrepareResult>;
finalizeUserCreation?(
entity: { id: string; values: Record<string, unknown> },
clearPassword?: string
): Promise<UserCreationFinalizeResult>;
/** Static service key to bypass checks for server-to-server calls */
serviceKey?: string;
}

Regardless of the external authentication provider chosen, your adapter must resolve successful token verifications to a uniform AuthenticatedUser object. The Rebase RLS Scope Injector maps these values directly to PostgreSQL session variables inside transactions:

export interface AuthenticatedUser {
uid: string; // Maps to pg local 'app.user_id' -> auth.uid()
email: string; // User email address
displayName?: string | null; // Optional display name
photoUrl?: string | null; // Optional avatar URL
roles: string[]; // Maps to pg local 'app.user_roles' -> auth.roles()
isAdmin: boolean; // Grants global superuser privileges if true
rawToken?: string; // The original token string (for downstream forwarding)
claims?: Record<string, any>; // Custom claims/metadata (available in auth.jwt())
}

Quick Integration via createCustomAuthAdapter

Section titled “Quick Integration via createCustomAuthAdapter”

For standard scenarios (such as validating JWTs from a third-party service), you can use the createCustomAuthAdapter utility. This utility handles capabilities defaults and implements WebSocket token validation out-of-the-box by wrapping your verifyRequest implementation.

To connect a Rebase backend with Clerk, you can verify Clerk JWT tokens using Clerk’s JSON Web Key Set (JWKS):

import { initializeRebaseBackend } from "@rebasepro/server";
import { createCustomAuthAdapter } from "@rebasepro/server";
import { createRemoteJWKSet, jwtVerify } from "jose";
// Clerk JWKS URL
const CLERK_JWKS_URL = "https://clerk.your-domain.com/.well-known/jwks.json";
const JWKS = createRemoteJWKSet(new URL(CLERK_JWKS_URL));
const clerkAuthAdapter = createCustomAuthAdapter({
serviceKey: process.env.REBASE_SERVICE_KEY,
verifyRequest: async (request) => {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
if (!token) return null;
try {
// Verify Clerk JWT token against JWKS
const { payload } = await jwtVerify(token, JWKS);
const metadata = payload.metadata as Record<string, unknown> | undefined;
const roles = Array.isArray(metadata?.roles) ? metadata.roles as string[] : [];
return {
uid: payload.sub!,
email: (payload as Record<string, unknown>).email as string || "",
displayName: (payload as Record<string, unknown>).name as string || null,
roles: roles,
isAdmin: roles.includes("admin"),
claims: payload as Record<string, unknown>
};
} catch (error) {
console.error("Clerk token verification failed:", error);
return null; // Fail-closed
}
},
capabilities: {
hasBuiltInAuthRoutes: false, // Login is managed by Clerk UI
emailPasswordLogin: false,
registration: false,
passwordReset: false,
profileUpdate: false,
sessionManagement: false
}
});
const backend = await initializeRebaseBackend({
auth: clerkAuthAdapter,
// ...
});

To verify Firebase Auth tokens using Firebase’s public certificates:

import { initializeRebaseBackend } from "@rebasepro/server";
import { createCustomAuthAdapter } from "@rebasepro/server";
import { createRemoteJWKSet, jwtVerify } from "jose";
const FIREBASE_JWKS_URL = "https://www.googleapis.com/robot/v1/metadata/jwk/securetoken@system.gserviceaccount.com";
const JWKS = createRemoteJWKSet(new URL(FIREBASE_JWKS_URL));
const FIREBASE_PROJECT_ID = "my-firebase-project-id";
const firebaseAuthAdapter = createCustomAuthAdapter({
serviceKey: process.env.REBASE_SERVICE_KEY,
verifyRequest: async (request) => {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
if (!token) return null;
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://securetoken.google.com/${FIREBASE_PROJECT_ID}`,
audience: FIREBASE_PROJECT_ID
});
const roles = Array.isArray((payload as Record<string, unknown>).roles) ? (payload as Record<string, unknown>).roles as string[] : [];
return {
uid: payload.sub!,
email: (payload as Record<string, unknown>).email as string || "",
displayName: (payload as Record<string, unknown>).name as string || null,
photoUrl: (payload as Record<string, unknown>).picture as string || null,
roles: roles,
isAdmin: roles.includes("admin"),
claims: payload as Record<string, unknown>
};
} catch (error) {
console.error("Firebase token verification failed:", error);
return null;
}
}
});
const backend = await initializeRebaseBackend({
auth: firebaseAuthAdapter,
// ...
});

If your custom auth provider requires mounting redirect endpoints (like OAuth callback routes or SAML login loops), implement the createAuthRoutes method on your adapter:

const myOauthAdapter: AuthAdapter = {
id: "custom-oauth",
verifyRequest: async (req) => ({
// validate the token, then return the caller
uid: "…",
email: "user@example.com",
roles: [],
isAdmin: false
}),
getCapabilities: () => ({
hasBuiltInAuthRoutes: true,
emailPasswordLogin: false,
registration: false,
passwordReset: false,
adminPasswordReset: false,
sessionManagement: false,
profileUpdate: false,
emailVerification: false,
magicLink: false,
enabledProviders: []
}),
createAuthRoutes: () => {
const app = new Hono<HonoEnv>();
// Mounted automatically under /api/auth/callback
app.get("/callback", async (c) => {
const code = c.req.query("code");
// Exchange code for provider tokens and set cookies/redirect
return c.redirect("/dashboard");
});
return app;
}
};

If you wish to allow user CRUD operations directly inside the Rebase Admin Dashboard, implement the userManagement helper within the adapter options, which provides hooks for listUsers, createUser, updateUser, and deleteUser.