Palbase
Sign inGet started

Web SDK

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:

PropertyTypeMeaning
pb.auth.isSignedInbooleanA refresh token is stored. This is the session truth on boot.
pb.auth.currentUserAuthUser | nullThe cached profile. null after a reload until it is fetched.
pb.auth.passkeysSupportedbooleanThis 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 onAuthStateChange snapshot after a reload can report signedOut for a tick even when isSignedIn is true, because no user is cached yet. Treat isSignedIn as 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) and signIn (POST /auth/login) resolve to an AuthSuccess ({ 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.

SettingDefaultRange
password_min_length86 to 64
password_max_length64between 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.codeMeaning
password_too_shortBelow this Environment's minimum. The message names the number.
password_too_longOver 64 characters.
password_breachedThe password appears in a public breach corpus (the stack checks it against a k-anonymity range API).

Note: password_breached surprises integrators whose fixtures use passwords like password123. 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_required is off unless you turn it on, so signUp returns access and refresh tokens and the user is signed in the moment the call resolves. user.emailVerified is false and stays false until 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.
  • verifyOTP resolves straight to an AuthSuccess — this route has no MFA branch.
  • A phone-only user has email: null on their AuthUser.
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 mfaRequired branch is a dead end inside @palbase/web. It tells you the account has a second factor and hands you an mfaToken and the factor names, and there is no method on pb.auth that completes the challenge. See What pb.auth does 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 SecurityError instead of degrading. passkeysSupported cannot 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));
ListenerFires withOn subscribe
onAuthStateChange(cb)AuthState{ status: 'signedIn', user } or { status: 'signedOut' }Immediately, with the current snapshot
onAuthEvent(cb)AuthChangeEventsignedIn / signedOut (with reason) / tokenRefreshedNothing replayed
onUserChange(cb)AuthUser, when refreshUser() detects a differenceReplays 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:

ResponseMeaning
401 session_revokedThis session was revoked
403 subject_fencedThe subject is being erased
403 subject_erasedThe 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, signedOut with reason accountDeleted. There is no signOut() 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: deleteAccount returns Promise<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:

MissingWhat is actually there
MFA enroll, challenge, verify, recovery codesOnly the mfaRequired union from verifyMagicLink / exchangeCodeForSession, and the mfa boolean from getSignInMethods()
Listing or revoking the user's own sessionsNothing on the client. An operator can use palbase auth sessions list / revoke
Linking or unlinking identities, listing trusted devicesNothing on the client
A password policy the form can readNothing — 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:

  1. Call the routes yourself. pb.call(name, body) POSTs to your Environment at that exact path, with the apikey and Authorization headers 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.call is always a POST, so a GET-shaped route such as /auth/mfa/factors needs a plain fetch instead — and pb.call does 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.

  2. Install @palbase/auth directly and drive its AuthClient.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. PalbeConfig has a storage field, but the only entry point that accepts a PalbeConfig is __configure on the @palbase/web/internal subpath, which the generated (do-not-edit) file calls. The one shipped way to swap the store is setupPalbeNext(), 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:

HeaderValue
apikeyyour publishable key, always
AuthorizationBearer <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:

MethodRoute
signUp / signIn / signOutPOST /auth/signup · POST /auth/login · POST /auth/logout
getUser / refreshUser / getSignInMethodsGET /auth/user · GET /auth/user · GET /auth/me
deleteAccountDELETE /auth/user
signInWithOTP / verifyOTPPOST /auth/otp · POST /auth/otp/verify
signInWithMagicLink / verifyMagicLinkPOST /auth/magic-link · POST /auth/magic-link/verify
verifyEmail / resendVerificationPOST /auth/verify-email · POST /auth/resend-verification
resetPassword / confirmPasswordReset / updatePassword / setPasswordPOST /auth/password/reset · …/reset/confirm · …/change · …/set
beginOAuth / completeOAuth / cancelOAuthPOST /auth/oauth/transactions · POST /auth/oauth/transactions/{id}/complete · POST /auth/oauth/transactions/{id}/cancel
passkeysPOST /auth/webauthn/{login,signup,register}/{begin,finish}

Warning — do not shim localStorage in Node. The SDK needs no shim: its default storage checks typeof localStorage !== 'undefined' and falls back to an in-memory store on its own, so the SDK works from Node as shipped. Installing a localStorage polyfill on globalThis actively breaks a server: it flips every runtime in the process onto one shared, persisted session store, which is exactly the cross-request session leak pbServer() exists to prevent.

  • Codegen — the generated file that configures pb
  • Error HandlingBackendError, its seven kinds, and isBackendError
  • Next.js — cookie sessions, the proxy that refreshes them, and handleAuthCallback
  • React HooksuseUser and useSession
  • 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