Authentication
Overview
Section titled “Overview”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.
Email / Password
Section titled “Email / Password”Sign In
Section titled “Sign In”const { user, accessToken, refreshToken } = await client.auth.signInWithEmail( "user@example.com", "password");console.log(user.uid, user.email);Sign Up
Section titled “Sign Up”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.
OAuth Providers
Section titled “OAuth Providers”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://..." });Other Providers
Section titled “Other Providers”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-inawait client.auth.signInWithApple(code, redirectUri, { name: { firstName: "Jane", lastName: "Doe" }, email: "jane@example.com"});
// Twitter — requires PKCE code verifierawait client.auth.signInWithTwitter(code, redirectUri, codeVerifier);Generic OAuth
Section titled “Generic OAuth”For any provider registered on the backend:
await client.auth.signInWithOAuth("custom-provider", { code: authCode, redirectUri: "https://myapp.com/callback"});Magic Links
Section titled “Magic Links”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.
One-Time Codes
Section titled “One-Time Codes”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.
Anonymous Sessions
Section titled “Anonymous Sessions”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; // trueThe 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 |
Linking a Provider to an Existing Account
Section titled “Linking a Provider to an Existing Account”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.
Looking Up a User by Email
Section titled “Looking Up a User by Email”const profile = await client.auth.findUserByEmail("user@example.com");// { uid, displayName, photoURL } | nullThree non-sensitive fields and nothing else — enough to show “you are inviting Jane” before an invitation is sent.
Multi-Factor Authentication
Section titled “Multi-Factor Authentication”TOTP factors — an authenticator app — plus the challenge that raises a session
from aal1 to aal2.
Enrolling a factor
Section titled “Enrolling a factor”const { factor, totp, recoveryCodes } = await client.auth.mfa.enroll({ friendlyName: "Phone"});
showQrCode(totp.uri); // otpauth://… — what the authenticator scansif (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");Signing in with MFA
Section titled “Signing in with MFA”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.
Removing a factor
Section titled “Removing a factor”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.
Sign Out
Section titled “Sign Out”await client.auth.signOut();This revokes the refresh token on the server, clears the local session, and emits a SIGNED_OUT event.
Session Management
Section titled “Session Management”Get Current Session
Section titled “Get Current Session”const session = client.auth.getSession();// { accessToken, refreshToken, expiresAt, user } | nullGet Current User (Server-Verified)
Section titled “Get Current User (Server-Verified)”const user = await client.auth.getUser();// Fetches the user from the backend (GET /auth/me)Update User Profile
Section titled “Update User Profile”const updatedUser = await client.auth.updateUser({ displayName: "Jane Doe", photoURL: "https://example.com/avatar.jpg"});Refresh Token
Section titled “Refresh Token”Token refresh happens automatically, but you can trigger it manually:
const session = await client.auth.refreshSession();Where the Session Lives: authFlowMode
Section titled “Where the Session Lives: authFlowMode”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.
Waiting for the Session to Be Restored
Section titled “Waiting for the Session to Be Restored”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.
Auth State Listener
Section titled “Auth State Listener”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 listeningunsubscribe();| 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 |
Password Management
Section titled “Password Management”Forgot Password
Section titled “Forgot Password”const { success, message } = await client.auth.resetPasswordForEmail( "user@example.com");Reset Password (with Token)
Section titled “Reset Password (with Token)”const { success, message } = await client.auth.resetPassword( resetToken, "newSecurePassword");Change Password (Authenticated)
Section titled “Change Password (Authenticated)”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).
Email Verification
Section titled “Email Verification”Registering mails the new account its verification link when email is
configured. sendVerificationEmail() mails it again.
// Send verification email to the current userawait 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.
Changing the Email Address
Section titled “Changing the Email Address”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 oneconst { 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.
Session Management (Multi-Device)
Section titled “Session Management (Multi-Device)”// List all active sessions, one per sign-inconst sessions = await client.auth.getSessions();// [{ id, userAgent, ipAddress, createdAt, isCurrentSession }, …]
// Revoke a specific sessionawait 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.
Auth Configuration
Section titled “Auth Configuration”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// }Custom Session Storage
Section titled “Custom Session Storage”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 localStorageconst 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 }});User Object Shape
Section titled “User Object Shape”// Canonical type — import from @rebasepro/typesinterface 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>;}Next Steps
Section titled “Next Steps”- Querying Data — CRUD operations and query builder
- Realtime Subscriptions — Live data with WebSockets
- Authentication Backend — Server-side auth configuration