Palbase
Sign inGet started

iOS SDK

Auth

pb.auth is the whole authentication surface of the iOS SDK: email and password, Sign in with Apple, Google, phone OTP, magic links, passkeys, password lifecycle, email verification, account deletion — backed by a session that lives in the Keychain and is restored on cold launch with no network round trip. Every method that talks to the auth service uses typed throws (throws(AuthError)), so a catch is exhaustive at the call site. You never see, store or refresh a token yourself.

Quick example

import Palbe
import SwiftUI

@MainActor @Observable
final class SessionStore {
    private(set) var user: AuthUser?
    private var unsubscribe: Unsubscribe?

    /// Call once from the root view's `.task`.
    func start() async {
        unsubscribe = await pb.auth.onAuthStateChange { [weak self] state in
            let next: AuthUser? = if case .signedIn(let user) = state { user } else { nil }
            Task { @MainActor [weak self] in self?.user = next }
        }
    }

    func signIn(email: String, password: String) async throws(AuthError) {
        try await pb.auth.signIn(email: email, password: password)
    }
}

onAuthStateChange replays the current state immediately on subscribe — including a session restored from a previous launch — so this one listener is all the session plumbing an app needs.

pb.auth ships in the base Palbe product. Nothing on this page needs PalbeMessaging or PalbeCall — but everything in those layers needs a signed-in user, so this is the surface they build on.

The surface

pb.auth is a public struct AuthNamespace: Sendable. Everything on it, grouped:

GroupMembers
PasswordsignUp(email:password:), signIn(email:password:), resetPassword(email:), confirmPasswordReset(token:newPassword:), updatePassword(currentPassword:newPassword:), setPassword(_:)
SocialsignInWithApple(), signInWithGoogle(), signInWithGoogle(clientID:redirectURI:)
PasswordlesssignInWithOTP(phone:channel:), verifyOTP(phone:token:), signInWithMagicLink(email:), verifyMagicLink(token:)
PasskeyssignInWithPasskey(), signUpWithPasskey(email:displayName:), registerPasskey(name:), stepUp(), localGate, wasCanceled(_:)
Accountcapability(), verifyEmail(token:), verifyEmail(code:email:), resendVerification(email:), getUser(), refreshUser(), currentUser, isSignedIn, signOut(), deleteAccount(password:)
ListenersonAuthStateChange(_:), onAuthEvent(_:), onUserChange(_:)

Not everything returns a session. The methods that mint one return AuthSuccess and are @discardableResult: signUp, signIn, signInWithApple, both signInWithGoogle overloads, verifyOTP, signInWithPasskey, signUpWithPasskey. signInWithOTP, signInWithMagicLink and registerPasskey return nothing — they are the first half of a two-step flow. verifyMagicLink returns a MagicLinkResult, which may or may not carry a session.

Note: There is no pb.auth.state, and no pb.auth.mfa. Read the session through currentUser / isSignedIn / onAuthStateChange, and see Multi-factor accounts for what the SDK does and does not do about a second factor.

Signing in binds analytics identity automatically — see Analytics.

Email and password

try await pb.auth.signUp(email: "dev@example.com", password: "correct-horse")

// …or for an existing account:
let success = try await pb.auth.signIn(email: "dev@example.com", password: "correct-horse")
print(success.user.id)

Wrong credentials throw AuthError.invalidCredentials. Password bounds are a per-Environment setting (password_min_length / password_max_length, defaults 8 and 64), as is whether a confirmed email address is required before a password login is accepted — both live on the stack and change with palbase auth settings set, not with a deploy. See Auth Settings.

OAuth

let result = try await pb.auth.signIn(with: .google)
switch result {
case .signedIn(let success): print(success.user.id)
case .mfaRequired(let token, let factors): /* present your MFA UI */ break
case .linked(let identity, let userID): /* explicit account linking completed */ break
}

signInWithGoogle(), signInWithApple(), signInWithMicrosoft() and signInWithGitHub() call the same configured flow. Configure clients in Studio or the CLI, then run palbase link. The plist carries the selected iOS/macOS snapshot; it never substitutes a Google web ID for an iOS ID. Runtime ID or redirect overrides are not accepted.

Google native sign-in uses the registered iOS client ID, bundle ID and redirect URI with PKCE in ASWebAuthenticationSession. Add the corresponding URL scheme to the app target. Microsoft native sign-in uses the configured application ID, directory and native redirect URI with PKCE. GitHub and clients configured in browser mode use the backend's browser transaction.

Apple native sign-in uses AuthenticationServices, the selected bundle ID and the transaction nonce. Add the Sign in with Apple capability in Xcode. Apple browser sign-in uses a separate Services ID and a server-side signing key. The native flow does not require that web credential. Apple may provide the user's name only during the first authorization; it is optional profile data, never proof of account ownership.

The SDK stores the completion proof in Keychain before presenting provider UI. Native ID tokens complete only the transaction that issued their nonce. Cancellation is reported with the oauth_cancelled error code. MFA and identity-link results do not create local sessions. Email matching never automatically links accounts.

For custom provider presentation use beginOAuth, completeOAuth and cancelOAuth; resumeOAuth(callback:) handles a stored transaction's exact callback. After a transient completion failure, completeOAuth(transaction) retries the previously stored result. Start account linking with linkIdentity(.google) from a recently authenticated account.

Phone OTP

// 1. Send the code. SMS is the only channel.
try await pb.auth.signInWithOTP(phone: "+15551234567")

// 2. Verify what the user typed — registers new users, signs in existing ones.
try await pb.auth.verifyOTP(phone: "+15551234567", token: "123456")

verifyOTP mints a session exactly like email sign-in. channel is OTPChannel.sms and that is the only case the enum has.

A wrong code, an expired one and a locked-out attempt all arrive as AuthError, but they need three different pieces of UI. Rather than adding enum cases, the SDK exposes a classifier:

do {
    try await pb.auth.verifyOTP(phone: phone, token: code)
} catch let error as AuthError {
    switch error.otpFailure {
    case .wrongCode:       fieldError = "That code isn't right."
    case .expired:         fieldError = "That code expired — send a new one."
    case .tooManyAttempts: fieldError = "Too many tries. Wait a moment."
    case nil:              show(error)     // not an OTP-specific failure
    }
}
otpFailureWire codes it folds
.wrongCodeotp_invalid, invalid_token, invalid_code
.expiredotp_expired, expired_token, verification_expired
.tooManyAttemptsotp_max_attempts, too_many_attempts
nilanything else — handle it as an ordinary AuthError

Note: Phone-only users have no email addressAuthUser.email is String? for exactly this reason. Never force-unwrap it.

Passkeys

The shortest sign-in there is: no email, no password, no code.

do {
    try await pb.auth.signInWithPasskey()
} catch {
    // A dismissed sheet is not a failure — show nothing for it.
    if !pb.auth.wasCanceled(error) { show(error) }
}

Passkeys are discoverable: the system sheet lists the credentials stored for this Environment's host (on the device, in iCloud Keychain, or on a nearby device) and the user picks one. That is why sign-in takes no identifier.

There must be an account with a passkey first. Either create one:

// No password is ever set for this account.
try await pb.auth.signUpWithPasskey(email: "dev@example.com", displayName: "Dev")

…or add one to an account that is already signed in, so the next sign-in is one tap:

try await pb.auth.registerPasskey(name: "iPhone")

Two things iOS requires first

iOS refuses a passkey request before it ever reaches Palbase unless both are true, and the error it raises does not say which one is missing.

1. Your app declares the Associated Domain. In Signing & Capabilities → Associated Domains, add the Environment's host — the same host as base_url in your Palbase-Info.plist:

webcredentials:k3xq81w4m.palbase.studio

During development you may append ?mode=developer so the device fetches the association file directly instead of waiting for Apple's CDN.

2. The Environment publishes your app's identifier. Every Environment serves the association document unauthenticated, and an Environment with no associated app answers with an empty list rather than a 404 — so a misconfigured app gets Apple's explicit "not associated with domain" instead of something that reads as an outage:

curl https://k3xq81w4m.palbase.studio/.well-known/apple-app-site-association
# {"webcredentials":{"apps":[]}}

What fills that list is the Environment's own auth setting passkey_apple_apps — a list of <TEAM_ID>.<bundle_id> identifiers. Nothing writes it for you: palbase link does not, and a deploy does not. Write it yourself through the stack's auth settings.

# Read the whole document first — the write replaces it.
palbase auth settings get

# Send it back with your app added.
palbase auth settings set --json '{
  "password_min_length": 8,
  "password_max_length": 64,
  "confirm_email_required": false,
  "passkey_apple_apps": ["ABCDE12345.com.example.app"]
}'

Warning: palbase auth settings set writes the whole auth document. Passing --json alone sends exactly what you typed, so a body that omits password_max_length sets it to zero and the stack rejects the write. Naming a typed flag (--password-min, --password-max, --confirm-email, --site-url) makes the CLI read the current document first and merge your --json into it — that is the safe shape when you only want to add one key.

A malformed identifier is rejected by the write rather than published broken, because a broken document surfaces on the device as the same "not associated with domain" error. Once the list is right, curl it again — the response is what Apple sees.

Adding a passkey needs a recent sign-in

registerPasskey(name:) requires the session to be RECENT, not merely valid. An enrolled passkey keeps working after the account's password is reset, so a session that was hijacked must not be able to add one. A stale session gets reauthentication_required.

do {
    try await pb.auth.registerPasskey(name: "iPhone")
} catch {
    // Re-authenticate, then retry. stepUp() uses an existing passkey; if the
    // account has none yet, sign in again instead.
    try await pb.auth.stepUp()
    try await pb.auth.registerPasskey(name: "iPhone")
}

What a passkey sign-in returns

The same AuthSuccess as every other rail. Persistence, refresh and the listeners behave identically — a passkey is a credential, not a different kind of session.

Biometrics: two different things

pb.auth exposes two mechanisms that both show a Face ID prompt and are easy to confuse. They are not interchangeable.

pb.auth.localGatepb.auth.stepUp()
What it isA lock over the session already stored on THIS deviceServer-side re-authentication
Who verifiesThe deviceThe auth service, against a real passkey assertion
Does the server know?No, and it must never be asked to trust itYes — it raises the session's assurance level
Use it forKeeping a stolen, unlocked phone out of a signed-in appProving identity before a sensitive action
public actor LocalGate {
    public var isBiometryAvailable: Bool { get }   // live LAContext check
    public var isEnabled: Bool { get }             // checked WITHOUT prompting
    public func enable() async throws
    public func disable() async                    // requires no biometry
    public func unlock(reason: String = "Unlock your session") async throws
}

public enum LocalGateError: Error, Sendable, Equatable {
    case biometryUnavailable
    case cancelled
    case biometryChanged
    case notEnabled
    case keychain(OSStatus)
}
// Arming it, from a settings screen.
if await pb.auth.localGate.isBiometryAvailable {
    try await pb.auth.localGate.enable()
}

// On a cold launch, before showing signed-in UI:
do {
    try await pb.auth.localGate.unlock()
} catch LocalGateError.biometryChanged {
    // A face or finger was enrolled since the gate was armed, which
    // invalidates the protected item BY DESIGN. Sign out; do not proceed.
    try await pb.auth.signOut()
} catch LocalGateError.notEnabled {
    // Nothing to unlock — carry on.
}

The gate stores its Keychain item under .biometryCurrentSet and kSecAttrAccessibleWhenUnlockedThisDeviceOnly, so enrolling a new face or finger invalidates it. That invalidation is the entire point: the threat the gate defends against is someone holding the unlocked phone, and .biometryAny would keep opening for a face they added themselves. isEnabled is read without prompting, so a launch screen can branch on it silently.

Warning: Never gate a server-side decision on localGate. The server is never told the gate exists. A debugger, a jailbroken device, or simply a caller that skips unlock() reaches exactly the same network surface. Use stepUp() when the server has to be convinced.

// 1. Email the user a sign-in link. No session yet.
try await pb.auth.signInWithMagicLink(email: "dev@example.com")

// 2. Complete sign-in with the token from the link, e.g. from your
//    deep-link handler.
let result = try await pb.auth.verifyMagicLink(token: token)
switch result {
case .signedIn(let success):
    print("welcome \(success.user.id)")   // session active, .signedIn fired
case .mfaRequired(let token, let factors):
    // The account has a second factor — NO session was created.
    startYourOwnMFAFlow(token: token, factors: factors)
}
public enum MagicLinkResult: Sendable {
    case signedIn(AuthSuccess)
    case mfaRequired(token: String, factors: [String])
}

The SDK never silently signs in a user whose account still requires a second factor, which is why this one method returns an enum instead of AuthSuccess.

Multi-factor accounts

Warning: MagicLinkResult.mfaRequired is the only place MFA appears in the iOS SDK. There is no pb.auth.mfa, no challenge API and no verify API — the SDK hands you the token and the factor list and stops. Completing the challenge is your app's own flow against the auth service.

From the operator side, a second factor is inspectable and resettable per user without any client involvement:

palbase auth mfa get <user-id>
palbase auth mfa reset <user-id>

Password: set, reset and change

// Forgot password — emails a reset link/token. Signs nobody in.
try await pb.auth.resetPassword(email: "dev@example.com")

// Complete the reset with the emailed token. Still no session —
// the user signs in afresh with the new password.
try await pb.auth.confirmPasswordReset(token: token, newPassword: "new-password")

// Change password while signed in. The current session stays valid.
try await pb.auth.updatePassword(currentPassword: "old", newPassword: "new")

// Give a PASSWORDLESS account its first password, in-app.
try await pb.auth.setPassword("a-strong-new-password")

setPassword vs updatePassword

They are not interchangeable, and picking the wrong one fails in a way that reads like a bug:

  • updatePassword(currentPassword:newPassword:) proves you know the current password. An account created with a passkey or a social provider has none, so this answers 401 for exactly the users who most want a password.
  • setPassword(_:) takes no current password because the account it serves does not have one. 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.

setPassword needs a RECENT sign-in, not merely a valid session — the same gate registerPasskey(name:) carries, for the same reason: a first password is a credential that outlives the rest. A stale session gets 403 reauthentication_required; sign the user in again (or complete a step-up) and retry.

do {
    try await pb.auth.setPassword(newPassword)
} catch let error as AuthError {
    if case .http(_, let code, _, _) = error {
        switch code {
        case "reauthentication_required":
            askToSignInAgain()          // then retry
        case "password_already_set":
            offerChangePassword()       // updatePassword, not setPassword
        case "password_too_short", "password_too_long", "password_breached":
            // All three are fixed by typing a different password.
            // `password_breached` means it appears in a known breach
            // corpus, not that it is malformed.
            showFieldError(error.localizedDescription)
        default:
            showToast(error.localizedDescription)
        }
    }
}
MethodRequires sessionRequires RECENT sign-inMints session
resetPassword(email:)NoNoNo
confirmPasswordReset(token:newPassword:)NoNoNo
updatePassword(currentPassword:newPassword:)YesNo— (keeps existing)
setPassword(_:)YesYes— (keeps existing)

Which credentials does this account have?

public struct AuthCapability: Sendable, Equatable {
    public let hasPassword: Bool
    public let providers: [String]?
}
let cap = try await pb.auth.capability()
if !cap.hasPassword {
    // Offer "Set a password" — setPassword(_:), not updatePassword.
}
if let providers = cap.providers, providers.contains("apple") {
    // "You signed in with Apple" — copy only.
}

capability() requires a session and reads GET /auth/me, so it answers with the account's real state rather than whatever the app remembered at sign-in. Two things to get right:

  • Branch on hasPassword, never on providers. A phone-OTP user has no password AND no linked provider, so an isEmpty test on providers strands exactly the user who most needs the password field offered to them.
  • providers is optional, and nil is not []. [] means "asked, none linked"; nil means the server could not determine it. Treat nil as unknown and hide credential actions rather than guessing.

The read is strict: hasPassword is a plain Bool, so a server that predates the field makes this throw rather than resolve to a half-answer.

Email verification

// From a verification link's token:
try await pb.auth.verifyEmail(token: token)

// Or from a short code the user types, plus the email it was sent to:
try await pb.auth.verifyEmail(code: "482913", email: "dev@example.com")

// Re-send the email if it expired or never arrived:
try await pb.auth.resendVerification(email: "dev@example.com")

The token and the code are always delivered by email; neither is ever returned by the API.

Picking up an out-of-band verification

Users often tap the verification link in a browser, outside your app — the server-side emailVerified flips and your app's cached user does not know. Call refreshUser() when the app returns to the foreground, or behind an "I verified my email" button:

@Environment(\.scenePhase) private var scenePhase

.onChange(of: scenePhase) { _, phase in
    if phase == .active {
        Task { try? await pb.auth.refreshUser() }
    }
}

refreshUser() re-fetches the user, updates the cache and fires onUserChange — your UI observes the flip with no session churn.

The current user

// Server round-trip — the authoritative profile.
let user = try await pb.auth.getUser()

// Re-fetch + update the cache + fire onUserChange. Session untouched.
let fresh = try await pb.auth.refreshUser()

// Last-known cached user, no network. Restored from the Keychain on cold
// launch, so it is available immediately for a returning user.
let cached = await pb.auth.currentUser

// True while a non-expired session exists.
let signedIn = await pb.auth.isSignedIn
APINetworkUpdates cacheFires
getUser()YesYes
refreshUser()YesYesonUserChange
currentUserNo
isSignedInNo

currentUser and isSignedIn are async properties — they read through the token actor, so await them.

Note: refreshUser() never emits onAuthStateChange — the session did not change, only a user property did. Sign-in/out transitions and property refreshes are deliberately separate channels.

Listening for changes

Three listeners, three jobs. Each is async and returns an Unsubscribe (@Sendable () -> Void) — keep it alive (in @State, or a store property) for as long as you want events, and call it to stop.

ListenerFires onReplays on subscribeUse for
onAuthStateChange.signedIn(user) / .signedOut transitionsYes — current stateUI gating (login vs. home)
onAuthEvent.signedIn, .signedOut(reason:), .tokenRefreshedNoSide effects: toasts, logs, analytics
onUserChangeCached-user property changesYes — current userProfile UI freshness
let unsubState = await pb.auth.onAuthStateChange { state in
    // Drives the UI. Replays current state immediately.
}

let unsubEvents = await pb.auth.onAuthEvent { event in
    if case .signedOut(reason: .sessionExpired) = event {
        // "Your session expired, sign in again" instead of a bare login screen.
    }
}

let unsubUser = await pb.auth.onUserChange { user in
    // e.g. emailVerified flipped after refreshUser().
}

Events fire alongside state changes, not instead of them — a signedOut(.sessionExpired) event arrives together with the .signedOut state. tokenRefreshed changes no state and is visible only through onAuthEvent.

Cold launch and session restore

The persisted unit is the session and the cached user together, stored in the app's Keychain (available after first unlock, this device only, never synced). A relaunch hydrates straight to .signedIn(user) with no network round trip.

The listeners are boot-gated. onAuthStateChange and onUserChange replay on subscribe, but while the Keychain is still loading the replay is deferred rather than answered with a guess — so an early subscriber never sees a spurious .signedOut flash and then a correction. Subscribe as early as you like; the first value you receive is the real one.

Two consequences worth knowing:

  • The first request of the launch waits for hydration before it goes out, and an already-expired token is refreshed opportunistically at that point rather than failing once and retrying.
  • A sign-in that beats hydration wins: loading from storage is skipped when a session already exists.

Token refresh

You never call it. Concurrent callers collapse onto a single in-flight refresh, and a 401 on an authenticated request triggers one refresh and one replay of that request.

What matters is when a failed refresh ends the session:

  • Fatal — HTTP 4xx, except 408 and 429. The Keychain is cleared and .signedOut(reason: .sessionExpired) fires.
  • Transient — 5xx, network failures, decode failures, an unavailable sealing key. The session is left intact and the next attempt tries again.

A successful silent refresh fires AuthEvent.tokenRefreshed and nothing else — no AuthState transition.

Note: If another device deletes the account, or a session is revoked server-side, this device discovers it on its next authenticated request: the SDK sees session_revoked (401) or subject_fenced (403), clears the local session and emits .signedOut(reason: .sessionExpired). An ordinary authorization denial — a plain 403 — never signs the user out.

Signing out

try await pb.auth.signOut()

signOut() always clears the local session, even if the server call fails — the teardown runs in a defer. A thrown error only means the server-side revocation did not go through; the device is signed out either way and .signedOut(reason: .userInitiated) fires. It is safe to try? this call.

Deleting an account

deleteAccount(password:) permanently erases the signed-in user. It requires fresh re-authentication: password users pass their password, passwordless and OAuth-only users omit it but must have a recently elevated session.

try await pb.auth.deleteAccount(password: "the-user-password")
// Returns nothing. On success the SDK has already torn down the local
// session and fired .signedOut(reason: .accountDeleted).

On success the server answers 202, the erasure is durably queued, and every one of the user's sessions is already revoked server-side — which is why the SDK does not call signOut() afterwards, and why you should not either. UI bound to onAuthStateChange routes to the login screen exactly as it does on a normal sign-out.

On failure the deletion did not happen and your local session is left intact. The wire code survives the throw, so branch on it:

error.codeStatusMeaning
reauth_required401The session is not freshly authenticated — re-authenticate or run stepUp(), then retry
not_configured503Account erasure is not available on this deployment
erasure_unavailable503Erasure is configured but the workflow could not be started — retry later
do {
    try await pb.auth.deleteAccount(password: password)
} catch {
    switch error.code {
    case "reauth_required":
        askToSignInAgain()                       // then retry
    case "not_configured":
        showToast("Account deletion isn't available here.")
    case "erasure_unavailable":
        showRetry()                              // transient
    default:
        showToast(error.localizedDescription)    // network or other
    }
}

Types reference

public struct AuthUser: Sendable, Equatable, Codable {
    public let id: String
    public let email: String?        // nil for phone-only OTP users
    public let emailVerified: Bool
    public let createdAt: String     // a String, not a Date
}

public struct AuthSuccess: Sendable {
    public let user: AuthUser
}

public struct Session: Sendable, Equatable, Codable {
    public let expiresAt: Int64      // unix seconds
    public var isExpired: Bool
}

public enum AuthState: Sendable, Equatable {
    case signedIn(AuthUser)
    case signedOut
}

public enum AuthEvent: Sendable, Equatable {
    case signedIn(AuthUser)
    case signedOut(reason: SignOutReason)
    case tokenRefreshed
}

public enum SignOutReason: Sendable, Equatable {
    case userInitiated     // pb.auth.signOut()
    case sessionExpired    // refresh rejected, or session_revoked / subject_fenced
    case accountDeleted    // pb.auth.deleteAccount(...) succeeded
}

Session carries no tokens: the access and refresh tokens are internal and an app cannot read them.

Warning: AuthUser.email is optional. Phone-OTP users never have one — handle nil everywhere you display an email.

AuthError

public enum AuthError: PalbaseError {
    case invalidCredentials(message: String)
    case rateLimited(retryAfter: Int?)
    case http(status: Int, code: String, message: String, requestId: String?)
    case network(message: String)
    case notConfigured
}
CaseWhencode
.invalidCredentials(message:)Wrong credentials, any other 401, or a cancelled system sheetinvalid_credentials
.rateLimited(retryAfter:)429; retryAfter in seconds when the server sends itrate_limited
.http(status:code:message:requestId:)Any other HTTP failure — code is the server's ownthe wire code
.network(message:)Transport, encoding or decoding failurenetwork_error
.notConfiguredZero-arg signInWithGoogle() with no enabled providernot_configured

AuthError conforms to PalbaseError, so code: String, statusCode: Int? and requestId: String? work uniformly across it and BackendError, and LocalizedError gives you a message for a generic presenter:

do {
    try await pb.auth.signIn(email: email, password: password)
} catch let error as AuthError {
    switch error {
    case .invalidCredentials:
        showFieldError("Wrong email or password.")
    case .rateLimited(let retryAfter):
        showToast("Too many attempts. Try again in \(retryAfter ?? 60)s.")
    default:
        showToast(error.localizedDescription)
    }
}

Two additive helpers that are not enum cases and are easy to miss: error.otpFailure (see Phone OTP) and pb.auth.wasCanceled(_:), which recognises exactly three cancellation messages — the passkey ceremony's, "User canceled Apple sign-in" and "User canceled Google sign-in". Present nothing for those; a toast saying the user changed their mind is noise.

Warning: Do not render AuthError.notConfigured's localized description to a user. The shipped string names pb.configure(apiKey:), which is not a public API — the SDK configures itself from Palbase-Info.plist. Switch on error.code instead.

pb.auth throws AuthError; calls to your own backend throw BackendError or a generated per-endpoint failure. See Error Handling for the full picture.

Wire paths

All of these are internal to the SDK, but they are the contract — and they are what you will see in a proxy or in the debug console.

MethodHTTP
signUp / signInPOST /auth/signup · POST /auth/login
signInWithApple / signInWithGooglePOST /auth/oauth/credential {provider, credential}
signInWithOTP / verifyOTPPOST /auth/otp · POST /auth/otp/verify
passkey login / signup / enrollPOST /auth/webauthn/{login,signup,register}/begin then …/finish
stepUpPOST /auth/step-up/webauthn/begin then POST /auth/step-up
resetPassword / confirmPasswordResetPOST /auth/password/reset · …/reset/confirm
updatePassword / setPasswordPOST /auth/password/change · POST /auth/password/set
capability / getUserGET /auth/me · GET /auth/user
verifyEmail / resendVerificationPOST /auth/verify-email · POST /auth/resend-verification
signInWithMagicLink / verifyMagicLinkPOST /auth/magic-link · …/verify
signOut / deleteAccountPOST /auth/logout · DELETE /auth/user
token refreshPOST /auth/token/refresh

Two transport rules follow from this table. Requests to /auth/anonymous, /auth/login, /auth/signup, /auth/token/refresh, /auth/password/reset, /auth/password/reset/confirm, /auth/magic-link, /auth/magic-link/verify, /auth/verify-email, /auth/resend-verification and /auth/oauth/credential never carry a Bearer token, so a stale session cannot poison a sign-in. And every /auth/ request body is sealed before it leaves the device — if the sealing key is unavailable the request is not sent and you get AuthError.network, rather than a plaintext body on the wire.

  • Overview — how pb configures itself, and what else is on it.
  • Codegen — where the OAuth client config in Palbase-Info.plist comes from.
  • Error HandlingBackendError, AuthError and generated failure enums.
  • Analytics — the identify/reset that rides the sign-in lifecycle.
  • Auth Settings — password bounds, providers, sessions, templates and MFA on the stack.
  • Backend Authentication@User() and route protection on the server side.