Skip to content

Authentication

The client.auth module handles user authentication, token management, and session persistence. Once a user signs in, all subsequent data requests automatically include the JWT.

The SDK persists sessions to localStorage by default and automatically refreshes tokens before they expire.

const { user, accessToken, refreshToken } = await client.auth.signInWithEmail(
"user@example.com",
"password"
);
console.log(user.uid, user.email);
const { user, confirmationRequired } = await client.auth.signUp(
"user@example.com",
"password",
"Jane Doe" // optional displayName
);

With requireEmailVerification on, there is no session until the address is confirmed: confirmationRequired is true and user is null, so tell the person to open the mail. Otherwise confirmationRequired is false and user is the signed-in account. On 0.23 signUp resolves { user, accessToken, refreshToken } and always signs in.

The SDK includes dedicated methods for popular OAuth providers, plus a generic signInWithOAuth() for any custom provider.

Supports three invocation styles:

// ID-token flow (One Tap / Sign In With Google button)
await client.auth.signInWithGoogle({ idToken: googleIdToken });
// Access-token flow (popup)
await client.auth.signInWithGoogle({ accessToken: googleAccessToken });
// Authorization code flow (most secure, server-side exchange)
await client.auth.signInWithGoogle({ code: authCode, redirectUri: "https://..." });

Each provider follows the authorization code flow with (code, redirectUri):

await client.auth.signInWithGitHub(code, redirectUri);
await client.auth.signInWithMicrosoft(code, redirectUri);
await client.auth.signInWithFacebook(code, redirectUri);
await client.auth.signInWithLinkedin(code, redirectUri);
await client.auth.signInWithDiscord(code, redirectUri);
await client.auth.signInWithGitLab(code, redirectUri);
await client.auth.signInWithBitbucket(code, redirectUri);
await client.auth.signInWithSlack(code, redirectUri);
await client.auth.signInWithSpotify(code, redirectUri);

Apple and Twitter require additional parameters:

// Apple — optional user info from first sign-in
await client.auth.signInWithApple(code, redirectUri, {
name: { firstName: "Jane", lastName: "Doe" },
email: "jane@example.com"
});
// Twitter — requires PKCE code verifier
await client.auth.signInWithTwitter(code, redirectUri, codeVerifier);

For any provider registered on the backend:

await client.auth.signInWithOAuth("custom-provider", {
code: authCode,
redirectUri: "https://myapp.com/callback"
});

A one-click sign-in link by email. The link is always <base>/auth/magic-link?token=…, where <base> is the backend’s email.magicLinkUrl, or its reset-password base (FRONTEND_URL on the runtime) when that is not set. There is no per-request redirectTo. Serve that path in your frontend, and hand the token back to trade it for a session. The CMS serves it, signing in (through the code step when there is a second factor); other frontends need a landing page.

// 1. Ask for the link.
await client.auth.sendMagicLink("user@example.com");
// 2. On the landing page, trade the token for a session.
const token = new URLSearchParams(location.search).get("token")!;
const { user } = await client.auth.verifyMagicLink(token);

sendMagicLink answers the same thing whether or not the address has an account. That is deliberate: an endpoint that said “no such user” is an account enumeration oracle, so do not use the result to tell a person whether they are registered — it does not know.

Both need an email service configured on the backend, or they answer 503 EMAIL_NOT_CONFIGURED.

A six-digit code by email, for the same job where a link is awkward — a native app, a second device, a browser that mangles links.

const { expiresInSeconds } = await client.auth.sendEmailOtp("user@example.com");
// The address goes back with the code, because the code is only valid for it.
const { user } = await client.auth.verifyEmailOtp("user@example.com", "418293");

Sending the address with the code is what keeps a six-digit guess a guess against one account rather than against every account at once.

Sign a visitor in with no credentials at all, so they can start using the app before they have a reason to sign up:

const { user } = await client.auth.signInAnonymously();
user.isAnonymous; // true

The account is real: it has an id, roles and a session, so row-level security scopes its rows exactly as it would a signed-up user’s. What it does not have is a way back — nobody can sign in as it a second time, so everything it owns is lost with the session.

linkAnonymous is how it stops being throwaway. The user keeps their id, so everything they created while anonymous stays theirs:

await client.auth.linkAnonymous("user@example.com", "correct-horse-battery");
Failure Means
ANONYMOUS_AUTH_DISABLED (403) The backend has not enabled anonymous auth
NOT_ANONYMOUS (400) The current session belongs to an ordinary account
EMAIL_EXISTS (409) The address already has an account — sign in to that one instead

signInWithGoogle and friends sign a user in. linkProvider attaches a provider identity to the account already signed in, so the same person can come back through either door:

await client.auth.linkProvider("google", { idToken });

The session already proves account ownership, so unlike sign-in this does not require the provider to have verified the email, and the two addresses need not match. It succeeds idempotently (alreadyLinked: true) when that identity is already on this account, and refuses with IDENTITY_ALREADY_LINKED (409) when it belongs to a different one.

const profile = await client.auth.findUserByEmail("user@example.com");
// { uid, displayName, photoURL } | null

Three non-sensitive fields and nothing else — enough to show “you are inviting Jane” before an invitation is sent.

TOTP factors — an authenticator app — plus the challenge that raises a session from aal1 to aal2.

const { factor, totp, recoveryCodes } = await client.auth.mfa.enroll({
friendlyName: "Phone"
});
showQrCode(totp.uri); // otpauth://… — what the authenticator scans
if (recoveryCodes) showRecoveryCodes(recoveryCodes);

Show the recovery codes once and never again. Only their hashes are stored, so nothing can display them later. They come with the account’s first factor. Adding another factor keeps the codes the account already has, and recoveryCodes is null. Starting an enrolment and abandoning it never touches them.

To replace them, after using several or losing the printout, call regenerateRecoveryCodes() from an aal2 session. The old codes stop working:

const { recoveryCodes } = await client.auth.mfa.regenerateRecoveryCodes();

Someone who lost both the authenticator and the codes is let back in by an administrator: client.admin.resetMfa(uid) (DELETE /api/admin/users/:uid/mfa, users:write) removes the account’s factors and codes and ends its sessions, so the password signs in again without a second factor. On 0.23 every enrolment returns a new set of codes that replaces the old one, and neither regenerateRecoveryCodes() nor resetMfa exists.

The factor is not usable until the user proves their authenticator produced a code from that secret:

await client.auth.mfa.verify(factor.id, "418293");

A sign-in against an MFA-enrolled account returns no session. It is refused with 401 MFA_REQUIRED, and the error’s details carry an mfaToken and the account’s verified factors. Pass that token to challenge and verifyChallenge to get the session:

import { RebaseApiError } from "@rebasepro/client";
type MfaRequired = {
mfaToken: string;
factors: { id: string; factorType: string; friendlyName?: string }[];
};
try {
await client.auth.signInWithEmail(email, password);
} catch (e) {
if (!(e instanceof RebaseApiError) || e.code !== "MFA_REQUIRED") throw e;
const { mfaToken, factors } = e.details as MfaRequired;
const { challengeId } = await client.auth.mfa.challenge(factors[0].id, { mfaToken });
// A TOTP code, or one of the recovery codes.
const { user } = await client.auth.mfa.verifyChallenge(challengeId, "418293", { mfaToken });
}

The mfaToken is sent on those two requests only, never installed on the client. verifyChallenge mints the aal2 session, this client adopts it, and it emits SIGNED_IN like any other sign-in. The mfaToken expires five minutes after the sign-in that returned it, and a challenge five minutes after it was opened. A challenge that has been guessed at its limit stays spent for the rest of its life — otherwise one open challenge is unlimited guesses at six digits.

Without mfaToken, the two calls step up the session this client already holds from aal1 to aal2.

await client.auth.mfa.unenroll(factorId);

Requires an aal2 session — one that has already answered a challenge — so a stolen aal1 token cannot turn MFA off. Removing the last verified factor also discards the recovery codes.

await client.auth.signOut();

This revokes the refresh token on the server, clears the local session, and emits a SIGNED_OUT event.

const session = client.auth.getSession();
// { accessToken, refreshToken, expiresAt, user } | null
const user = await client.auth.getUser();
// Fetches the user from the backend (GET /auth/me)
const updatedUser = await client.auth.updateUser({
displayName: "Jane Doe",
photoURL: "https://example.com/avatar.jpg"
});

Token refresh happens automatically, but you can trigger it manually:

const session = await client.auth.refreshSession();
const client = createRebaseClient({
baseUrl: API_URL,
auth: { authFlowMode: "cookie" }
});
Mode Where the refresh token is When to use it
"json" (default) Returned in the response body, kept in localStorage A native app, a script, anything without a browser’s cookie jar
"cookie" An HttpOnly cookie the backend sets A browser app. Script running on your page cannot read it, which is what makes it XSS-safe

Cookie mode needs auth.cookieAuth on the backend, and it is what the generated frontend template uses.

A restored session is not available on the first render. getSession() is synchronous, so on page load it answers null while the restore is still in flight — and in cookie mode a restore is always in flight, because the refresh token is in a cookie the page cannot read, so the client has to ask the server for a fresh access token.

Reading it synchronously is what produces a logged-out flash on every reload:

// Wrong: renders the signed-out view for one round trip, every reload.
const session = client.auth.getSession();
if (!session) return <SignIn />;

isInitialized() resolves once the client has finished trying — whether it found a session or not:

async function currentUser() {
await client.auth.isInitialized();
return client.auth.getSession()?.user ?? null;
}

In React, that is one effect:

import { useEffect, useState } from "react";
function useCurrentUser() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
client.auth.isInitialized().then(() => {
if (cancelled) return;
setUser(client.auth.getSession()?.user ?? null);
setLoading(false);
});
return () => { cancelled = true; };
}, []);
return { user, loading };
}

useRebaseAuthController in @rebasepro/app already does this, so an app built on the generated template gets it for free.

A successful restore also reaches onAuthStateChange as TOKEN_REFRESHED — it is a refresh — but a listener alone cannot tell you that the restore finished: a boot with no session emits nothing at all, which is indistinguishable from one still in progress. Await isInitialized() for that question and use the listener for changes after it.

React to authentication changes across your application:

const unsubscribe = client.auth.onAuthStateChange((event, session) => {
// event: "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED"
console.log("Auth event:", event);
console.log("Session:", session?.user?.email);
});
// Stop listening
unsubscribe();
Event When
SIGNED_IN A sign-in or sign-up completed
TOKEN_REFRESHED The access token was renewed — including the silent renewal that restores a session on page load
USER_UPDATED updateUser() changed the profile, or confirmEmailChange() moved this account to its new address
SIGNED_OUT A sign-out, or a refresh that failed for good
const { success, message } = await client.auth.resetPasswordForEmail(
"user@example.com"
);
const { success, message } = await client.auth.resetPassword(
resetToken,
"newSecurePassword"
);
const { success, message } = await client.auth.changePassword(
"oldPassword",
"newPassword"
);

Every other session of the account ends: whoever else held one has to sign in with the new password. This device stays signed in. The server answers with a fresh session, and the client adopts it (emitting TOKEN_REFRESHED).

Registering mails the new account its verification link when email is configured. sendVerificationEmail() mails it again.

// Send verification email to the current user
await client.auth.sendVerificationEmail();
// Verify with the token from the email link. Signed in as that account, this
// keeps everything on it.
const { passwordRemoved } = await client.auth.verifyEmail(token);
// Signed out, an account that holds a password answers PROOF_REQUIRED:
try {
await client.auth.verifyEmail(token);
} catch (e) {
if (e instanceof Error && "code" in e && e.code === "PROOF_REQUIRED") {
// Keep the password, and sign in:
await client.auth.verifyEmail(token, { password });
// …or verify without it, which removes it:
// await client.auth.verifyEmail(token, { removeUnproven: true });
}
}

The link proves the inbox, not who registered the address, so a password neither the session nor the call proves is removed rather than kept — see Email verification.

On a backend with requireEmailVerification, signUp() resolves with { confirmationRequired: true, user: null } and no session: the account signs in once the link is followed with its password.

A signed-in user moves their own account to another address. Nothing changes until the new address answers:

// Mails a link to the new address, and a notice to the current one
const { pendingEmail, expiresAt } = await client.auth.changeEmail("jane@new.example");
// On the page the link opens (<frontend>/confirm-email-change?token=…):
const { email, removedProviders } = await client.auth.confirmEmailChange(token);

The link lives 24 hours; getUser() reports the waiting address as pendingEmail. Confirming needs no session, and a client signed in as that account takes the new address and emits USER_UPDATED. The refusals are listed in Changing an email address.

// List all active sessions, one per sign-in
const sessions = await client.auth.getSessions();
// [{ id, userAgent, ipAddress, createdAt, isCurrentSession }, …]
// Revoke a specific session
await client.auth.revokeSession(sessionId);
// Revoke ALL sessions (logs out everywhere)
await client.auth.revokeAllSessions();

Every access token names the sign-in it belongs to, so exactly one entry has isCurrentSession: true: the device asking. Revoking a session ends that device’s refresh token and its access token together, from the next request, over HTTP and over an open realtime socket. Signing out (signOut()) does the same for the device that signs out. An access token issued before this release names no session; it keeps working until it expires, within the hour.

Query the backend’s authentication configuration:

const config = await client.auth.getAuthConfig();
// {
// hasBuiltInAuthRoutes: boolean,
// emailPasswordLogin: boolean,
// registrationEnabled: boolean, // open right now, bootstrap window included
// passwordReset: boolean, // needs an email service
// adminPasswordReset: boolean,
// sessionManagement: boolean,
// profileUpdate: boolean,
// emailVerification: boolean,
// magicLink: boolean,
// anonymousLogin: boolean,
// enabledProviders: string[],
// needsSetup: boolean
// }

By default, sessions are stored in localStorage. You can customize this with the auth option:

import { createRebaseClient, createCookieStorage } from "@rebasepro/client";
// Use cookies instead of localStorage
const client = createRebaseClient({
baseUrl: import.meta.env.VITE_API_URL,
auth: {
storage: createCookieStorage({
path: "/",
sameSite: "Lax",
secure: true
}),
autoRefresh: true, // default: true
persistSession: true // default: true
}
});
// Canonical type — import from @rebasepro/types
interface User {
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
providerId: string;
isAnonymous: boolean;
emailVerified?: boolean;
pendingEmail?: string | null; // an address change waiting for its link
roles?: string[]; // text[] from the users table
metadata?: Record<string, unknown>;
}