Auth
pb.auth is the browser's whole authentication surface: password sign-up and sign-in, phone OTP, magic links, passkeys, OAuth, password reset, email verification, listeners, and self-service account deletion. Every method returns a value and throws on failure — there is no { data, error } envelope. Sessions are restored from browser storage on load and every other pb call carries the signed-in user's access token without any wiring. This page also states plainly what pb.auth cannot do, because several things a reader expects to find here — MFA completion, session listing, identity linking — are not on this surface at all.
Quick example
// The palbase/client barrel configures pb as a side effect of being imported once.
import './palbase/client';
import { pb } from '@palbase/web';
const { user, session } = await pb.auth.signUp({
email: 'dev@example.com',
password: 'correct-horse-battery-staple-7',
});
console.log(user.id, user.emailVerified); // '<id>', false
await pb.auth.signIn({ email: 'dev@example.com', password: 'correct-horse-battery-staple-7' });
// Every backend call is authenticated from here on — your controllers see this user.
const myTodos = await pb.todos.list();
There is no createClient(url, key) and no user-callable configure(). The generated palbe.gen.ts calls __configure({ url, apiKey, appId }) from the @palbase/web/internal subpath at import time; your app imports the palbase/client barrel that re-exports it, so one import both configures pb and types it. See Codegen.
Session state and boot
Two properties tell you where you stand:
| Property | Type | Meaning |
|---|---|---|
pb.auth.isSignedIn | boolean | A refresh token is stored. This is the session truth on boot. |
pb.auth.currentUser | AuthUser | null | The cached profile. null after a reload until it is fetched. |
pb.auth.passkeysSupported | boolean | This browser can run a WebAuthn ceremony. |
In the browser the runtime fires a background profile fetch at construction, so a restored session is synchronously signed in while currentUser is still null for a tick. The canonical boot sequence:
import { pb } from '@palbase/web';
if (pb.auth.isSignedIn) {
const user = await pb.auth.refreshUser(); // fetch + cache the profile
showApp(user);
} else {
showLogin();
}
Warning: The immediate
onAuthStateChangesnapshot after a reload can reportsignedOutfor a tick even whenisSignedInistrue, because no user is cached yet. TreatisSignedInas the truth and let the listener correct itself when the profile arrives.
Two reads of the profile:
const user = await pb.auth.getUser(); // GET /auth/user — does not touch the cache
const user = await pb.auth.refreshUser(); // GET /auth/user + update currentUser + notify onUserChange
Prefer refreshUser(). In React, useUser() and useSession() wrap all of this — see React Hooks.
Email and password
const { user, session } = await pb.auth.signUp({ email, password });
const { user, session } = await pb.auth.signIn({ email, password });
await pb.auth.signOut();
signUp(POST /auth/signup) andsignIn(POST /auth/login) resolve to anAuthSuccess({ user, session }) and persist the session.signOut()(POST /auth/logout) clears the local session even if the server call fails, so an offline sign-out still signs the user out of this device.
The password policy lives on the Environment
There is no client-side password policy surface in @palbase/web — no getPasswordPolicy, no validator, no length constants. The policy is a per-Environment setting and it is enforced server-side, so the only way your form learns about it is by getting an error back.
| Setting | Default | Range |
|---|---|---|
password_min_length | 8 | 6 to 64 |
password_max_length | 64 | between the minimum and 64 |
Change them with palbase auth settings set --password-min 12 --password-max 64 — see Auth Settings. There are no composition rules (no required symbol, digit or case mix).
Three refusals arrive as a BackendError with status 400 and these codes:
err.code | Meaning |
|---|---|
password_too_short | Below this Environment's minimum. The message names the number. |
password_too_long | Over 64 characters. |
password_breached | The password appears in a public breach corpus (the stack checks it against a k-anonymity range API). |
Note:
password_breachedsurprises integrators whose fixtures use passwords likepassword123. Use high-entropy values in tests and seeds.
What actually happens to a new account
A signup creates a verification token and sends a verification email — always, on every signup, before the response is written. What it does not do is stop anybody.
- Nothing is gated by default. The Environment setting
confirm_email_requiredis off unless you turn it on, sosignUpreturns access and refresh tokens and the user is signed in the moment the call resolves.user.emailVerifiedisfalseand staysfalseuntil somebody verifies it. - The mail carries a six-digit code, not a link — the default verification method is
code, and the code expires in five minutes. - If the Environment has no email sender configured, no mail is sent and the signup still succeeds. There is no default managed email or SMS sender on the runtime; a send failure is recorded in the audit trail and never fails the signup. Configure a sender with
palbase notifications add sendgrid …— see Stack Settings.
So the honest description of a new user on a stock Environment is: they fill in two fields, they are signed in immediately, and they separately receive a code that nothing asks them for.
If you want verification to matter, you have to build both halves:
// 1. Turn the gate on, once, per Environment:
// palbase auth settings set --confirm-email=true
// 2. Accept the code in your own UI:
await pb.auth.verifyEmail({ code, email }); // the six-digit code from the mail
await pb.auth.verifyEmail({ token }); // or a link token, if you switched the method
await pb.auth.resendVerification('dev@example.com');
await pb.auth.refreshUser(); // so currentUser.emailVerified flips in your UI
With confirm_email_required on, signUp returns the created user with no tokens (the token fields are absent, not empty), and a password login for an unverified address is refused with 403 email_not_confirmed after the password has been checked. Nothing in Palbase ships a screen that collects the code; that screen is yours.
Phone OTP
await pb.auth.signInWithOTP({ phone: '+15551234567' }); // POST /auth/otp
const { user } = await pb.auth.verifyOTP({ phone: '+15551234567', token: '123456' }); // POST /auth/otp/verify
- Delivery is SMS only. The SDK hard-codes
channel: 'sms'and the server rejects anything else, so there is no channel option to pass. - It needs an SMS sender on the Environment (
palbase notifications add twilio …). Without one, no message is sent. verifyOTPresolves straight to anAuthSuccess— this route has no MFA branch.- A phone-only user has
email: nullon theirAuthUser.
Magic links
await pb.auth.signInWithMagicLink('dev@example.com'); // POST /auth/magic-link
// On the page the emailed link opens, carrying its token:
const result = await pb.auth.verifyMagicLink(token); // POST /auth/magic-link/verify
if (result.status === 'signedIn') {
console.log(result.user.email); // session established
} else {
// result.status === 'mfaRequired' — see the warning below
console.log(result.mfaToken, result.factors);
}
type MagicLinkResult =
| ({ status: 'signedIn' } & AuthSuccess)
| { status: 'mfaRequired'; mfaToken: string; factors: string[] };
Warning: The
mfaRequiredbranch is a dead end inside@palbase/web. It tells you the account has a second factor and hands you anmfaTokenand the factor names, and there is no method onpb.auththat completes the challenge. See Whatpb.authdoes not do for the two ways out.
Passkeys
A passkey signs the user in with Face ID, Touch ID or Windows Hello and no typed identifier — the credential is discoverable, so the browser lists the accounts stored for your Environment and the user picks one.
// Ask FIRST. A browser without WebAuthn throws rather than degrading, so the
// button needs a password or email path beside it.
if (pb.auth.passkeysSupported) {
const { user } = await pb.auth.signInWithPasskey();
}
Create an account whose only credential is a passkey — no password ever exists for it:
await pb.auth.signUpWithPasskey('dev@example.com', 'Dev');
Or add one to the account that is already signed in:
await pb.auth.registerPasskey('This laptop');
registerPasskey requires a recent sign-in, not merely a valid session: an enrolled passkey keeps working after a password reset, so a hijacked session must not be able to add one. A stale session gets 403 reauthentication_required — re-authenticate and retry.
One passkey, iOS and web
The relying party is your Environment's host, so a passkey enrolled in your iOS app is the same credential the browser offers, and the method names match the iOS SDK exactly.
For a web app served from your own domain, the browser has to be told that the Environment vouches for that origin. It fetches https://<ref>.palbase.studio/.well-known/webauthn, and that document is published from the Environment's passkey_web_origins auth setting. Set it in Authentication → Settings → App associations, or update only that field with the CLI:
palbase auth settings get
palbase auth settings set --json '{"passkey_web_origins":["https://app.example.com"]}'
Two limits worth knowing before you design around it:
- Five registrable domains. Browsers that implement Related Origin Requests stop after five and silently skip the rest, so the stack rejects an over-budget list at write time rather than publishing one that works for only some users.
- Not universal. Related Origin Requests are off by default in Firefox for Android, and a client without them throws
SecurityErrorinstead of degrading.passkeysSupportedcannot detect that in advance, so a working password or email path is a requirement, not a nicety.
Passkeys do not cross Environments: a credential enrolled against production will not work against staging. That is the isolation working as intended.
OAuth
Configure a web browser client for Google, Apple, Microsoft or GitHub and run palbase link. The generated oauth snapshot selects one client per provider, application and variant.
Browser redirect
await pb.auth.signInWithOAuth({
provider: 'google',
redirectTo: 'https://app.example.com/auth/callback',
});
The SDK starts a transaction and stores its completion proof in this tab's session storage before navigating. The backend handles provider state, PKCE and token verification. Your callback receives only transaction_id and result_code:
const params = new URLSearchParams(location.search);
const result = await pb.auth.exchangeCodeForSession({
transactionId: params.get('transaction_id')!,
resultCode: params.get('result_code')!,
callbackURL: location.href,
});
switch (result.status) {
case 'signedIn': /* session installed; result.user and result.isNewUser */ break;
case 'mfaRequired': /* hand result.mfaToken and result.factors to your MFA UI */ break;
case 'linked': /* result.identity was linked to result.userId */ break;
}
The callback URL must exactly match the selected client's registered return URI before its two result parameters. Do not append a provider, state or next parameter. For custom navigation use beginOAuth or signInWithOAuth({ provider, redirect: false }). Google and Apple convenience methods delegate to the same generic flow.
After a temporary completion failure, pb.auth.completeOAuth({ transactionId }) retries the stored result with the same proof. cancelOAuth(transactionId) cancels a pending transaction. A replacement result code is rejected. Transactions expire after ten minutes, and browser storage must be available; another browser or tab cannot complete this tab's transaction.
For Next.js server callbacks, start with pbServer() in a Server Action or Route Handler so its HttpOnly proof cookie reaches handleAuthCallback. See Next.js OAuth. A browser session-storage transaction is completed in the browser.
Account linking
Sign in with the existing account first, then start an explicit link transaction:
const transaction = await pb.auth.linkIdentity({
provider: 'github',
redirectTo: 'https://app.example.com/auth/callback',
});
location.assign(transaction.authorizationURL);
Linking requires a recent verified sign-in. Matching email addresses never silently merge accounts, replace passwords or transfer identities. A linked result does not create another session. An identity already belonging to another account is refused.
Adding a password to a social account
A user who started with Google has no password, so updatePassword — which requires the current one — is useless to exactly the people who want one. setPassword takes no current password, because the account it serves does not have one:
await pb.auth.setPassword(newPassword); // POST /auth/password/set
It sets the password in place: no email, no linked page, and the session stays valid. An account that already has a password gets 409 password_already_set and must use updatePassword, so this can never overwrite a password without knowing it. It also requires a recent sign-in — a first password is a credential that outlives the rest — and a stale session gets 403 reauthentication_required.
To decide which form to show, ask which credentials the account actually has:
const { password, providers, mfa } = await pb.auth.getSignInMethods(); // GET /auth/me
if (password) {
// 'Change password' — updatePassword works.
} else {
// 'Set a password' — setPassword, or send them through the reset flow.
}
providers is [] when nothing is linked and null when the server could not determine it. Treat null as unknown, not as none.
Password reset and change
// Not signed in — request the mail, then confirm with the token it carries:
await pb.auth.resetPassword('dev@example.com'); // POST /auth/password/reset
await pb.auth.confirmPasswordReset({ token, newPassword }); // POST /auth/password/reset/confirm
// Signed in, and the account has a password:
await pb.auth.updatePassword({ currentPassword, newPassword }); // POST /auth/password/change
The reset flow signs the user out everywhere; setPassword does not. Both ride the Environment's email sender, so on a stack with no sender configured resetPassword resolves and no mail arrives.
Listening for auth changes
All three listeners return an Unsubscribe (() => void), and a callback that throws never breaks the others.
// Signed-in/out state. Fires immediately with the current snapshot.
const off = pb.auth.onAuthStateChange((state) => {
if (state.status === 'signedIn') render(state.user);
else renderLogin();
});
// Discrete events:
pb.auth.onAuthEvent((event) => {
switch (event.type) {
case 'signedIn': console.log('hello', event.user.email); break;
case 'signedOut': console.log(event.reason); break;
case 'tokenRefreshed': break;
}
});
// Profile changes (replays the cached user on subscribe):
pb.auth.onUserChange((user) => updateAvatar(user));
| Listener | Fires with | On subscribe |
|---|---|---|
onAuthStateChange(cb) | AuthState — { status: 'signedIn', user } or { status: 'signedOut' } | Immediately, with the current snapshot |
onAuthEvent(cb) | AuthChangeEvent — signedIn / signedOut (with reason) / tokenRefreshed | Nothing replayed |
onUserChange(cb) | AuthUser, when refreshUser() detects a difference | Replays the cached user, if any |
The signedOut reason separates four cases: an explicit signOut() (userInitiated), a session the platform could no longer refresh (sessionExpired), this device's own deleteAccount() (accountDeleted), and a server-side session kill observed on a request (sessionInvalid). Show a you-were-signed-out notice only for the involuntary ones.
Reactive sign-out
Every pb call routes through one transport choke point that watches for a server-side session kill. Exactly three responses kill the local session, clear storage and emit signedOut with reason sessionInvalid — once:
| Response | Meaning |
|---|---|
401 session_revoked | This session was revoked |
403 subject_fenced | The subject is being erased |
403 subject_erased | The subject is gone |
An ordinary 403 — a normal authorization or row-level-security denial — does not sign the user out. Neither does an ordinary 401, which is instead refreshed and retried once.
Delete account
Self-service erasure, and it is synchronous: the Environment answers 204 No Content and by then the account is gone. One DELETE FROM auth.users is carried into every schema by foreign keys, sessions included. There is no workflow id to poll and nothing left running.
import { isBackendError } from '@palbase/web';
try {
await pb.auth.deleteAccount({ password }); // DELETE /auth/user
// never reached today — see the warning below
} catch (e) {
if (isBackendError(e)) {
// The deletion did NOT happen and the user is still signed in.
// 401 reauth_required — wrong or missing password; prompt and retry.
// 401 missing_session — no session at all.
} else {
// The deletion DID happen: the local session is already torn down.
}
}
- An account with a password must supply it. A passwordless or OAuth-only account deletes on the authority of the session alone.
- On success the SDK tears the local session down itself — tokens cleared, storage cleared,
signedOutwith reasonaccountDeleted. There is nosignOut()to call afterwards. - On a refusal the session is left fully intact, because the request throws before the teardown line.
- Other devices holding that account sign themselves out on their next request, through reactive sign-out.
Note:
deleteAccountreturnsPromise<void>. There is nothing to read from it — the account is gone and the local session is already torn down when it resolves.
What pb.auth does not do
Stop looking for these on pb.auth — none of them exist on this facade:
| Missing | What is actually there |
|---|---|
| MFA enroll, challenge, verify, recovery codes | Only the mfaRequired union from verifyMagicLink / exchangeCodeForSession, and the mfa boolean from getSignInMethods() |
| Listing or revoking the user's own sessions | Nothing on the client. An operator can use palbase auth sessions list / revoke |
| Linking or unlinking identities, listing trusted devices | Nothing on the client |
| A password policy the form can read | Nothing — the server refuses and you read err.code |
The MFA gap is the one that bites. The full surface (/auth/mfa/enroll, /auth/mfa/verify, /auth/mfa/challenge, /auth/mfa/recovery, /auth/mfa/factors, and the email variants) exists on the Environment and on @palbase/auth's AuthClient, which @palbase/web bundles internally but does not re-export. Two ways forward if you need to finish an MFA sign-in in a browser:
-
Call the routes yourself.
pb.call(name, body)POSTs to your Environment at that exact path, with theapikeyandAuthorizationheaders already attached — so the POST-shaped MFA routes are reachable with no extra wiring:// result.status === 'mfaRequired' → finish it against the Environment: const tokens = await pb.call('auth/mfa/challenge', { mfa_token: result.mfaToken, type: 'totp', // or 'email' code, // what the user typed }); // A recovery code goes to 'auth/mfa/recovery' with { mfa_token, code }.Both routes answer with access and refresh tokens.
pb.callis always a POST, so a GET-shaped route such as/auth/mfa/factorsneeds a plainfetchinstead — andpb.calldoes not persist the session it just obtained, so you are storing those tokens yourself. You are working below the facade here; nothing types these bodies. -
Install
@palbase/authdirectly and drive itsAuthClient.mfa, which owns the typed methods and the session wiring.
Sessions and storage
In the browser the session is persisted in localStorage; outside a browser the default is an in-memory store. On load, a stored access token that is still fresh is reused as-is — no pre-flight refresh, which is what stops a per-request server runtime burning a token rotation on every render. A stored session that is expired, or that carries only a refresh token, triggers one refresh before the first real request.
interface PersistedSession {
refreshToken: string; // the durable credential
accessToken?: string;
expiresAt?: number; // ms epoch
}
interface SessionStorageAdapter {
load(): PersistedSession | null;
save(session: PersistedSession): void;
clear(): void;
}
@palbase/web exports two adapters, localStorageSessionStorage(key?) and memorySessionStorage().
Note: There is no supported public hook for installing a custom adapter.
PalbeConfighas astoragefield, but the only entry point that accepts aPalbeConfigis__configureon the@palbase/web/internalsubpath, which the generated (do-not-edit) file calls. The one shipped way to swap the store issetupPalbeNext(), which does exactly that for cookie-backed sessions — see Next.js.
Two projects on one origin collide
The storage key is suffixed with the identity the SDK parses out of your publishable key — and on the Palbase cloud that identity is the compile-time constant project for every Environment. The keys are therefore literally:
palbe.session.project the session
palbe.flags.project the flags snapshot
palbe.analytics.id.project the anonymous analytics id
palbe.analytics.optout.project the opt-out choice
palbe-session-project the cookie, when the Next.js proxy is used
Warning: Two Palbase projects served from the same browser origin do collide. Signing in against one overwrites the session of the other. This page used to promise the opposite. If you need two Environments in one page, put them on different origins. See the two API keys.
How requests authenticate
Once signed in, every pb call — typed namespaces, pb.call, uploads, flags, realtime — carries the access token automatically. Two headers travel together on each request:
| Header | Value |
|---|---|
apikey | your publishable key, always |
Authorization | Bearer <accessToken>, when a session exists |
The publishable key is never sent on Authorization, and the access token is never sent on apikey. On a 401 that is not session_revoked, the SDK refreshes the session once and retries the request once, reusing the same Idempotency-Key so a mutation cannot be applied twice. If the refresh fails terminally, the session is cleared and the original error is rethrown.
Proof of work
/auth/signup and the token routes sit behind a bot gate. An unsolved request is answered 403 pow_required with a challenge in the body; the SDK finds a nonce whose SHA-256 has the required leading zero bits and repeats the request with X-PoW-Challenge-ID and X-PoW-Nonce. Your code never sees it — except in two failure modes worth knowing:
- A challenge harder than difficulty 24 is refused by name rather than attempted.
- The solver calibrates against this machine's real hashing rate first, and if the expected solve would exceed the 120 second budget it refuses in milliseconds, naming the numbers, instead of hanging. A slow device on a hard challenge therefore fails fast rather than freezing your sign-up form.
Auth failures throw BackendError like everything else, carrying the server's code, status and requestId — see Error Handling. On the backend side, read the verified user in your controllers with @User — see Authentication.
Types
interface AuthUser {
id: string;
email: string | null; // null for phone-only users
emailVerified: boolean;
createdAt: string;
}
interface AuthSuccess {
user: AuthUser;
session: Session; // { accessToken, refreshToken, expiresAt /* ms epoch */ }
}
interface SignInMethods {
password: boolean;
mfa: boolean;
providers: string[] | null; // [] = none linked; null = could not determine
}
type AuthState =
| { status: 'signedIn'; user: AuthUser }
| { status: 'signedOut' };
type AuthChangeEvent =
| { type: 'signedIn'; user: AuthUser }
| { type: 'signedOut'; reason: 'userInitiated' | 'sessionExpired' | 'accountDeleted' | 'sessionInvalid' }
| { type: 'tokenRefreshed' };
type Unsubscribe = () => void;
AuthUser, AuthSuccess, AuthState, AuthChangeEvent, SignInMethods, MagicLinkResult, OAuthExchangeResult and Unsubscribe are all exported from @palbase/web.
Raw HTTP and non-browser use
pb.auth is the supported path, but the SDK is a thin wrapper over ordinary HTTP, and the routes are the fastest way to reach something the facade does not expose (MFA, session listing). They live at your Environment's address — https://<ref>.palbase.studio — which is not derivable from the key: the ref in pb_project_c… is the stack's own constant, not your address. Configure both explicitly, always.
Every request carries the publishable key on the apikey header:
# Sign up
curl -X POST https://k3xq81w4m.palbase.studio/auth/signup \
-H 'apikey: pb_project_c<32 base62 chars>' \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"correct-horse-battery-staple-7"}'
# Sign in (the path is /auth/login, not /auth/signin)
curl -X POST https://k3xq81w4m.palbase.studio/auth/login \
-H 'apikey: pb_project_c<32 base62 chars>' \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"correct-horse-battery-staple-7"}'
Both return the same snake_case body on success:
{
"access_token": "<jwt>",
"refresh_token": "<token>",
"expires_in": 3600,
"user": { "id": "usr_…", "email": "dev@example.com", "email_verified": false, "created_at": "…" }
}
On every authenticated call afterwards send both headers — apikey: <publishable> and Authorization: Bearer <access_token>. Refresh a stale access token with POST /auth/token/refresh and body {"refresh_token":"…"}.
The routes behind each facade method, so you can reach any of them directly:
| Method | Route |
|---|---|
signUp / signIn / signOut | POST /auth/signup · POST /auth/login · POST /auth/logout |
getUser / refreshUser / getSignInMethods | GET /auth/user · GET /auth/user · GET /auth/me |
deleteAccount | DELETE /auth/user |
signInWithOTP / verifyOTP | POST /auth/otp · POST /auth/otp/verify |
signInWithMagicLink / verifyMagicLink | POST /auth/magic-link · POST /auth/magic-link/verify |
verifyEmail / resendVerification | POST /auth/verify-email · POST /auth/resend-verification |
resetPassword / confirmPasswordReset / updatePassword / setPassword | POST /auth/password/reset · …/reset/confirm · …/change · …/set |
beginOAuth / completeOAuth / cancelOAuth | POST /auth/oauth/transactions · POST /auth/oauth/transactions/{id}/complete · POST /auth/oauth/transactions/{id}/cancel |
| passkeys | POST /auth/webauthn/{login,signup,register}/{begin,finish} |
Warning — do not shim
localStoragein Node. The SDK needs no shim: its default storage checkstypeof localStorage !== 'undefined'and falls back to an in-memory store on its own, so the SDK works from Node as shipped. Installing alocalStoragepolyfill onglobalThisactively breaks a server: it flips every runtime in the process onto one shared, persisted session store, which is exactly the cross-request session leakpbServer()exists to prevent.
Related
- Codegen — the generated file that configures
pb - Error Handling —
BackendError, its seven kinds, andisBackendError - Next.js — cookie sessions, the proxy that refreshes them, and
handleAuthCallback - React Hooks —
useUseranduseSession - the two API keys — the two keys, and why the ref is not in the key
- Auth Settings — password policy, providers, sessions and templates on the Environment
- Stack Settings — configuring the email and SMS senders these flows need
- Authentication — reading the verified user in your controllers
- Auth — the iOS counterpart of this page