Authentication
The starter templates — web (Vue) and iOS (SwiftUI) — come with a complete authentication flow out of the box: login UI, token management, session handling, and multiple sign-in methods. You don't need to build auth screens or wire up the client yourself. Not using a template? Every flow is also available directly on the client — see Using the Client Directly.
The two things you may want to configure are:
- Email sign-in — on by default; one setting turns it off entirely
- Google OAuth — Optional, requires setting up a Google OAuth client
Server App Settings Must Match Your App's Origin
Authentication runs against server-side app settings that must line up with where your client app is actually served. These settings live in your synced app.toml — edit the TOML and run primitive config push so the server matches what's checked into your repo. Three settings matter, and a mismatch in the first one fails in a particularly confusing way:
| Setting | What it does | Where it lives |
|---|---|---|
| CORS allowed origins | The whitelist of origins allowed to call the Primitive API from a browser. Your serving origin (scheme + host + port) must be listed. | [cors].allowedOrigins in app.toml |
| Google redirect URIs | The callback URLs of one Google client. The callback your app uses must match exactly — it is what SELECTS the client at the callback — or the flow fails with Invalid redirect URI. | [auth.google.clients.<type>].redirectUris in app.toml |
| Email redirect URIs | The allow-list for the sign-in link. Fail-closed both ways: with an empty or missing list the sign-in email carries the code alone, and a request that supplies a redirect target this list doesn't cover is rejected outright with 400 Invalid redirect URI — it does not fall back to a code-only email. A request that supplies no target asks for a code-only email and never consults the list. An http/https entry matches by ORIGIN (one origin, many paths); a custom scheme matches by SCHEME + AUTHORITY, so myapp://auth covers myapp://auth/magic-link but no other scheme or host. New apps are seeded with http://localhost:5173/oauth/callback, and primitive init appends the dev-port callback for a non-default port, so a fresh app sends links with no configuration. | [auth].emailRedirectUris in app.toml |
| Base URL | Where the app is served — used for links in auth emails and redirects. | [app].baseUrl in app.toml |
Inspect all three anytime with primitive apps get.
All three are app settings, so app.toml is the only place to change them from the CLI — there is no flag that sets one. primitive config push --only app applies the settings without touching the rest of your config.
The Template Login
Both starter templates ship a drop-in login flow:
<PrimitiveLogin
appName="My App"
defaultContinueRoute="home"
onboardingRoute="onboarding"
/>// AuthGateView shows PrimitiveLoginView until the user is signed in
// and connected, then renders your content.
AuthGateView(
appState: appState,
appName: "My App",
authManager: appState.authManager
) {
MainTabView()
}| Web (Vue) | iOS (SwiftUI) | |
|---|---|---|
| Component | PrimitiveLogin | PrimitiveLoginView, wrapped by AuthGateView |
| Email sign-in | One email carrying both the code and the link — nothing to configure | Same one email and the same screen, but code-only by default — nothing to configure either, and no allow-list entry needed. The link is a two-line opt-in: Make the emailed sign-in link open your app |
| Social sign-in | Google button appears automatically when configured | Google and Sign in with Apple buttons appear automatically when configured (Google runs in an ASWebAuthenticationSession sheet) |
| After sign-in | New users pass through the template's /onboarding step (profile completion + passkey prompt), then land on defaultContinueRoute | Renders the AuthGateView content closure |
Email Sign-In (One Email, One or Two Credentials)
There is no method to choose. One request sends one email carrying a 6-digit code — and, when the request supplies an allow-listed redirect target, a sign-in link as well. The user finishes with whichever suits them: typing the code on the device they started on, or clicking the link. Consuming either one retires both: one email signs the user in once.
Whether the email carries a link, and what that link is, follows the shape of your app:
- Web-only: the template supplies its own
/oauth/callbackas the redirect target, and new apps are seeded with the localhost dev callback in the allow-list — so the email carries both, with nothing to configure. - iOS-only: code-only by default.
PrimitiveAuthManagersupplies no redirect target, so the email carries the code alone and works with no app settings at all. A custom-scheme link is dead in the Simulator and dead on any device that doesn't have the app installed, so the code is what always works. Turning that link on is a two-line opt-in — see Make the emailed sign-in link open your app. - iOS + web: both clients send the SAME
https://<your-web-domain>/oauth/callback. Tapped on a device with the iOS app installed, iOS opens the app (a universal link); read anywhere else — another device, the Simulator, a desktop browser — it is your web app's normal sign-in page. One link, wherever the mail is read. Set the environment'swebUrland the iOS client sends it with no code change.
The Vue PrimitiveLogin component and the iOS PrimitiveLoginView both render that single flow: one email field, then one "check your email" state with the code entry and the reminder that the link works too when the email has one.
Two rules are worth knowing up front:
- The link is issued fail-closed. The email carries a link only when the request has a redirect target AND that target matches the app's non-empty
[auth].emailRedirectUrisallow-list. With no redirect target, or an empty allow-list, the same template renders code-only. A target that misses a non-empty allow-list is not degraded to a code-only email — the whole request is rejected with 400Invalid redirect URI. - You can remove the link. Edit the link block out of your app's
email-sign-inemail template and no endpoint can emit a link email — every email sign-in request renders that one template. Keep{{code}}in both bodies: it is the credential every sign-in email carries, and a template that drops it is refused when you save it.
Turn email sign-in off entirely — for an app that only wants Google sign-in — with emailSignInEnabled = false under [auth] in app.toml.
Make the Emailed Sign-In Link Open Your App
Doing nothing is a working configuration: an app that names no redirect target gets sign-in emails carrying the 6-digit code alone, from the moment the app is created, with no emailRedirectUris entry. The code is the credential that works regardless of the link's shape — read on the wrong device, in the Simulator, in a webmail client that strips links. Everything below is about adding the link.
Pick the setup that matches your app:
| Your app | The link it emails | What it takes |
|---|---|---|
| Web only | https://<your-domain>/oauth/callback | nothing — the web client sends it and init seeds the allow-list |
| iOS + web | the SAME https://<your-domain>/oauth/callback, which opens the iOS app where it is installed | set webUrl and serve the association file |
| iOS only | <scheme>://auth/magic-link | the two-step scheme opt-in |
iOS + web: one https link
This is the shape to prefer whenever you have a web deployment. Both clients send one https:// URL, so there is nothing per-platform about the request and nothing to decide when the email is sent — where it opens is decided by where it is READ.
Point the environment at your web app.
webUrlis a normalized origin on the Primitive environment, per environment, because the callback has to be served by a deployment talking to the same backend and app:sh# when you CREATE the environment: primitive env add prod --api-url https://primitiveapi.com --app-id app_… \ --web-url https://app.example.com # for an environment that already exists (init wrote `dev`), edit # primitive/config.json: environments.prod.webUrl = "https://app.example.com"An origin means scheme, host and port and nothing else: https (or
httponlocalhost/127.0.0.1for a dev server), no credentials, no path, no query, no fragment. The CLI refuses anything else with a field-named error, and the readers that build the app — the Swift template's pre-build script, the Vite plugin — ignore a value that is not an origin rather than pointing the link somewhere it cannot work.primitive initseeds this for you in dev when it scaffolds both clients, with the localhost origin whose callback it also allow-lists, so the dev link works out of the box. Two runs seed nothing and print the steps instead: one that ADDS a client to a project it does not own, and one whose app already existed with an allow-list that does not carry the dev callback (settingwebUrlthere would turn a working code-only email into a 400). The Swift build carries the selected environment's value intoprimitive.json;PrimitiveAppState.initialize()sets it asclient.links.appBaseURL, which is both where the emailed link points AND the origin an incoming universal link is trusted from. One value, so the two cannot disagree.Allow-list the https callback — merge
https://app.example.com/oauth/callbackinto[auth].emailRedirectUrisinprimitive/dev/app.tomlandprimitive config push --only app(see the merge rules below).Declare the iOS app id on that environment, and deploy. The association document Apple fetches names ONE app, and an app has one bundle id per environment — so it is not a file you commit. Add
iosAppIdbeside the same environment'swebUrl:json"prod": { "apiUrl": "https://primitiveapi.com", "webUrl": "https://app.example.com", "iosAppId": "ABCDE12345.com.example.app" }sh# when you are creating the environment: primitive env add prod --api-url https://primitiveapi.com --app-id app_… \ --web-url https://app.example.com --ios-app-id ABCDE12345.com.example.appThe value is Apple's
<Application Identifier Prefix>.<bundle id>. The prefix is 10 characters and is usually your Team ID — some apps' differ, so verify it against the signed app'sapplication-identifierentitlement.webUrlandiosAppIdgo together: one says where the web app is, the other which phone app that origin vouches for, and aniosAppIdwithout awebUrlis refused.pnpm cf-deploythen writespublic/.well-known/apple-app-site-associationfor the environment it is deploying, before the build — so alpha serves the alpha app id and production serves the production one, with no file to edit between deploys. An environment with noiosAppIdserves no association document at all, which is the right answer for a web-only environment and much better than serving another environment's app id. Verify what the domain returns:shcurl -i https://app.example.com/.well-known/apple-app-site-association # want: 200, content-type: application/json, no redirect, the JSON bodyThe generated document records which environment's deploy wrote it, in the
applinkscomponent'scomment— so a document naming the wrong app is readable from thatcurlinstead of failing silently. To write the document by hand instead (several appIDs, anappclipssection), createpublic/.well-known/apple-app-site-association.hand-authoredbeside it; the deploy then leaves your file alone. The generated path is gitignored, so taking that route also means dropping that line from the template's.gitignoreand committing BOTH files — a sentinel that reaches a fresh clone without the document deploys with no association document at all. The template'spublic/.well-known/README.mdhas the three steps. Without that sentinel, a document the deploy did not write is a hard error rather than something shipped blind.The
applinkscomponent is query-scoped:json{ "/": "/oauth/callback", "?": { "magic_token": "*" } }That constraint matters — the same path is where Google's OAuth redirect lands with a
?code=query, and a path-only claim would hand those redirects to the app, which has no page to render them. The template'spublic/_headersserves the extensionless file asapplication/json.Enable the entitlement last. In the iOS client's
project.yml, uncommentcom.apple.developer.associated-domainsand name your domain (applinks:app.example.com). It needs a realDEVELOPMENT_TEAM. Do this only after step 3 verifies: the entitlement is the only thing that makes Apple fetch the document, and Apple's CDN caches what it gets.
Universal links arrive as an NSUserActivity, not through .onOpenURL — that is the ONLY delivery on macOS, and how a link handed off from another device arrives. The Swift template routes both; a hand-written app needs .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) alongside .onOpenURL.
Installed builds that send the custom scheme. Each emailRedirectUris entry stands on its own. A build whose environment has a webUrl sends the https target — the manager prefers it over the scheme whenever a web counterpart is configured — while a build without one that turned the link on sends <scheme>://auth/magic-link. Keep the scheme entry allow-listed alongside the https one for as long as builds of the second kind are installed; remove it once none are, because a build still sending it then fails every sign-in request with 400 Invalid redirect URI.
The tradeoff. The web domain lives in the app's environment configuration, which is compiled into the build — so changing the domain later means an app release. In exchange the link is one URL, the same for every client, and the server sees an ordinary allow-listed redirect target.
The association document is served from your domain. Apple fetches it from the web domain the link names, so it must be served from a domain you control. An app with no web deployment cannot have universal links; it has the custom scheme, and the code.
iOS only: the custom-scheme opt-in
Skip this if your app has a web client — use the https link above instead.
primitive init already did two of the four pieces for you: it stamped an app-unique URL scheme into your client's Info-Partial.plist (under the CFBundleURLTypes entry named PrimitiveAuth — the same entry PrimitiveAuthManager reads its callbackScheme from, so the two can't disagree), and the template's ContentView already routes incoming URLs with .onOpenURL → routePlatformLink. Your scheme is printed at the end of the init run and looks like myapp-01j8x2c9v3qk7f4hs0d6re5wtn.
Two steps are left:
Allow-list the callback. Merge
<scheme>://auth/magic-linkinto the existing[auth].emailRedirectUrisarray in your app'sprimitive/dev/app.toml, then push the settings:sh# If primitive/dev/app.toml isn't in your repo yet, pull it first: primitive config pull --only app # Edit primitive/dev/app.toml — MERGE into the existing array, don't replace it: # [auth] # emailRedirectUris = [ # "http://localhost:5173/oauth/callback", # "myapp-01j8x2c9v3qk7f4hs0d6re5wtn://auth/magic-link", # ] primitive config push --only appapp.tomlis the whole truth about app settings on push, so a file listing only your new entry deletes the ones already there. A custom scheme matches on scheme + authority, somyapp://authcoversmyapp://auth/magic-link.Turn the link on in the app. Before requesting a sign-in email:
swiftappState.authManager.sendsEmailSignInLink = true(A manager constructed with an explicit
PrimitiveAuthManager(callbackScheme:)starts with this on — an app that named its own scheme allow-listed it deliberately.)
What each missing piece looks like
Each of the four pieces fails differently, and mostly silently:
| Missing piece | Symptom |
|---|---|
| Allow-list entry (with the link flag on) | Every email sign-in request fails with 400 Invalid redirect URI — the server does not fall back to a code-only email. Nobody can sign in. |
sendsEmailSignInLink = true | Sign-in works, but the email carries only the code. No error anywhere. |
The CFBundleURLTypes registration (custom template, or a scheme you changed by hand) | The emailed link is a dead tap: nothing launches, nothing logs. |
.onOpenURL → routePlatformLink routing | The app opens from the link and nobody signs in. |
How far a custom-scheme link reaches
A myapp:// link only opens on a device that has your app installed. It is dead in the Simulator, dead when the user reads the email on another device, and many webmail clients won't render a non-http(s) href as a clickable link at all. That's why code-only is the iOS default and why the 6-digit code is the credential to design around. One https:// link that opens the app where it is installed and falls back to the web everywhere else is the iOS + web setup.
The same scheme also carries your OAuth callback, <scheme>://oauth/callback, when you use startOAuth() — so if you add a Google iOS client, that URI is what goes in [auth.google.clients.ios].redirectUris.
Setting Up Google OAuth (Optional)
Google OAuth lets users sign in with their Google account. This requires a one-time setup in both the Google Cloud Console and your Primitive app.
1. Create a Google OAuth Client
Go to the Google Cloud Console OAuth page and create a Web application client:
| Setting | Value |
|---|---|
| Authorized JavaScript origins | http://localhost:5173 |
| Authorized redirect URIs | http://localhost:5173/oauth/callback |
Note your Client ID and Client Secret.
Production Setup
When deploying to production, add your production domain to these settings alongside localhost.
2. Configure in Primitive
Google registers an OAuth client per platform, so app.toml states one entry per client type — web, ios, android, desktop, chrome-extension — each with its own clientId and redirectUris. web and desktop clients also carry a clientSecret; ios, android and chrome-extension clients must not — Google issues no secret for those types, and the exchange proves possession with PKCE instead. A redirect URI belongs to the client that redirects and selects it at the callback, so it may appear in only one entry: an iOS custom scheme is never a valid redirect for the web client.
Store the Client Secret as an app secret first: clientSecret holds a {{secrets.KEY}} reference to it, never the secret itself (in the Admin Console the field is a picker over the app's secrets). A literal value is rejected with GOOGLE_CLIENT_SECRET_MUST_BE_SECRET_REF, and a reference to a key that doesn't exist with MISSING_GOOGLE_CLIENT_SECRET_REF. Then set the secret, your Client ID, and the client's callback URLs alongside the provider toggle and your dev origin (see Server App Settings), and push:
primitive secrets set GOOGLE_CLIENT_SECRET --value <client-secret># primitive/dev/app.toml
[auth]
googleOAuthEnabled = true
emailRedirectUris = ["http://localhost:5173/oauth/callback"]
[auth.google.clients.web]
clientId = "1234-web.apps.googleusercontent.com"
clientSecret = "{{secrets.GOOGLE_CLIENT_SECRET}}"
redirectUris = ["http://localhost:5173/oauth/callback"]
[cors]
mode = "custom"
allowedOrigins = ["http://localhost:5173"]primitive config pushThat's it — the template's login component automatically shows a "Sign in with Google" button when Google OAuth is configured.
The server sends every stored clientSecret back verbatim — a {{secrets.KEY}} reference is a pointer, not a credential — so apps get, config pull and the Admin Console all show which secret an entry uses. An entry that holds the secret itself rather than a reference still signs users in, but it can't be saved back until you store the value as an app secret and re-point clientSecret at it, or remove the client. Other app-settings writes are unaffected.
GOOGLE_OAUTH_MISCONFIGURED is the code for a stored clientSecret that can't be resolved — a reference naming a secret that doesn't exist (for example one deleted after the entry was configured), or reference syntax that no {{secrets.KEY}} reference accounts for. Web sign-in fails closed with that code before the request reaches Google. Native (PKCE) sign-in isn't failed closed the same way — a code verifier alone can prove possession — but a confidential client whose secret doesn't resolve still fails at Google, as a generic INVALID_TOKEN.
Passkeys (WebAuthn)
Passkeys let returning users sign in with biometrics (fingerprint, face) or hardware security keys. The web template's PrimitiveLogin flow supports passkeys automatically — the onboarding step prompts users to register one after sign-in, whichever method they signed in with, and they can use it for future logins.
On iOS, the client wraps Apple's AuthenticationServices in two one-call helpers — client.auth.signInWithPasskey() and client.auth.registerPasskey(deviceName:) — and the iOS template offers passkey enrollment after a first sign-in by another method, then lists and manages saved passkeys from the profile screen. Passkeys require the app's associated-domains entitlement to list your RP domain; see Deep links and universal links.
Naming the relying party
An app can configure more than one relying party — typically a dev host alongside the real one. A browser tells the server which one it is using through the request's Origin header, but a native request has none, so a native client should name its relying party itself: set AuthConfig(passkeyRpId: "your-app.example.com") on the Swift client (or pass rpId: to signInWithPasskey / registerPasskey), and pass { rpId } to passkeyAuthStart() / passkeyRegisterStart() in the JS client. Use the same host as the app's webcredentials: entitlement. The value must be one of the app's configured relying parties; an unconfigured one is rejected with PASSKEY_RP_NOT_CONFIGURED rather than quietly replaced by another, which is what would make the Apple sheet fail with "not associated with domain".
An app built on PrimitiveAppState gets this for free: initialize() names the host of the selected environment's webUrl — the same relying party a browser served your app from that origin is given — so a passkey ceremony asks for the RP your web client already uses, with no app code. An IP-literal webUrl (http://127.0.0.1:5173) names nothing, because an IP address is never a valid relying-party id; that shape, and an app with no webUrl, leave the choice to the server.
Two override points, in order of reach:
// Every ceremony in this app: override the hook, no reimplemented initialize().
override func passkeyRpId(for config: PrimitiveAppConfig) -> String? {
"example.com" // or nil, to let the server pick
}
// One ceremony: the manager's per-call argument, bookkeeping intact.
await authManager.signInWithPasskey(rpId: "example.com")
await authManager.enrollPasskey(deviceName: "iPhone", rpId: "example.com")The webUrl host must be a configured relying party
Because initialize() names the webUrl host, an app whose webUrl host is not one of its relying parties — passkeyRpConfig naming example.com while webUrl is https://app.example.com — fails every native ceremony with PASSKEY_RP_NOT_CONFIGURED. Fix it either way: override passkeyRpId(for:) to return the configured host (or nil to leave the choice to the server), or add the webUrl host to the app's passkeyRpConfig.
One Account Across Sign-In Methods
A person who signs in with Google today and Apple tomorrow lands on the same account, as long as both identities carry the same email. The second provider is linked to the existing account rather than starting a new one, so documents, group memberships, and permissions carry over — there is nothing for your app to do.
Once a provider is linked, it keeps resolving to that account even if the email on it later changes: the identity itself is what's matched, not just the address. And a provider identity stays with the account it was first linked to — signing in with it never moves it to a different account.
Only Google and Apple sign-in create these durable links. Email sign-in identifies a user by the email address they entered, and passkeys are registered against an account the user is already signed in to.
Email Template Customization
The email-sign-in email ships with a built-in default you can override with your own subject and branded HTML and text body — including deleting the {{#if magicLink}} block if you want a code-only email. It is one of the email types Primitive sends — see Email Templates for the full list, the template variables each type exposes, and the CLI commands to override, test, and revert them.
Invitations and Pending Shares
Signing in is what resolves anything waiting on a user's email — a pending app invitation, plus any document shares, group adds, or collection adds addressed to them. On first sign-in, with any method, it's all applied automatically; there's no manual "accept invitation" step, so the things other people invited them into are already there. See Invitations for how those shares are created and the full resolution rules.
Deep links and universal links
On iOS, an app receives Primitive URLs through universal links or a custom scheme — an invitation to accept, a shared document to open, or a magic-link callback. client.links turns an incoming URL (or the NSUserActivity a universal link delivers) into a typed target, so you route it without parsing query strings:
client.links.appBaseURL = URL(string: "https://app.example.com")
let target = try await client.links.resolve(userActivity: activity)
switch target {
case .document(let id): openDocument(id)
case .invitation(let token): try await client.invitations.accept(inviteToken: token)
case .magicLink(let token, _): try await client.auth.magicLinkVerify(token: token)
default: break
}The same API builds the outbound links — client.links.shareURL(forDocument:) and client.links.inviteAcceptURL(inviteToken:) produce the canonical URLs (the invite URL matches what Primitive's default invitation emails use). Configuring the app's associated domains for universal links is also what passkeys require; the iOS template ships the entitlement and a link router.
PrimitiveAppState.initialize() sets appBaseURL for you from the selected environment's webUrl, so an app scaffolded with both clients trusts its own web origin without the assignment above. A universal link is delivered as an NSUserActivity — .onContinueUserActivity(NSUserActivityTypeBrowsingWeb), which is the only delivery on macOS — while a custom-scheme URL arrives through .onOpenURL; the template handles both.
Disabling a User Per App
Admins can disable a user's access to a single app from the admin console without deleting their global account. A disabled user's existing tokens are revoked, open WebSocket connections are dropped, and any subsequent sign-in attempt (passkey, OAuth, magic link, OTP) is rejected with the error code AUTH_USER_DISABLED. Re-enabling restores access immediately; no re-invitation is needed.
Display AUTH_USER_DISABLED in your sign-in UI as a distinct error so the user understands they should contact an admin rather than retrying or trying a different method.
Test User Sign-In
Test users are the recommended way to sign in during everyday development, and the same mechanism drives integration tests: whitelist a base email per app, and derived addresses like alice+primitivetest-teacher@example.com sign in through the normal OTP flow — your app's real login UI — with the magic code 000000. No real email round-trip, and one mailbox covers a whole fleet of role-distinguished test users.
Each whitelisted base authorizes unlimited derived addresses of the form <base-local>+primitivetest<suffix>@<base-domain>. The whitelist is testAccountBaseEmails in the [app] table of app.toml, capped at 50 base emails per app — and a base cannot itself be a +primitivetest derivative:
# primitive/dev/app.toml
[app]
testAccountBaseEmails = ["alice@example.com", "bob@example.com"]Apply it with primitive config push --only app, and inspect the current whitelist (alongside other app settings) with primitive apps get. Clear the whitelist by setting it to an empty array and pushing again. From the test side, sign in via the normal OTP flow using the magic code 000000:
// Requires the app owner to have added "alice@example.com" to the app's
// testAccountBaseEmails whitelist. Then any `alice+primitivetest<suffix>@example.com`
// derivative becomes a test account that accepts code "000000".
await client.emailSignInRequest("alice+primitivetest@example.com");
await client.otpVerify("alice+primitivetest@example.com", "000000");
// client is now authenticated; the access token expires in 30 minutes
// Role-distinguished derivatives (Gmail/Workspace deliver them to the same inbox):
await client.emailSignInRequest("alice+primitivetest-teacher@example.com");// Requires the app owner to have added "alice@example.com" to the app's
// testAccountBaseEmails whitelist. Then any `alice+primitivetest<suffix>@example.com`
// derivative becomes a test account that accepts code "000000".
_ = try await client.auth.emailSignInRequest(email: "alice+primitivetest@example.com")
_ = try await client.auth.otpVerify(email: "alice+primitivetest@example.com", code: "000000")
// client is now authenticated; the access token expires in 30 minutes
// Role-distinguished derivatives (Gmail/Workspace deliver them to the same inbox):
_ = try await client.auth.emailSignInRequest(email: "alice+primitivetest-teacher@example.com")A first sign-in provisions the derived account through the same signup path as a real user, and the response's isNewUser reflects whether the account was just created — so first-run and new-user flows can be exercised through the bypass. The app's signup-mode gates apply exactly as for a normal signup: an invite-only app still requires an invitation or invite token (or adds the address to the waitlist when that's enabled), and domain mode still rejects disallowed domains. The whitelist is what keeps provisioning safe — only the app owner's own +primitivetest derivatives are eligible.
Guardrails
- Per-app whitelist. Apps without a whitelist have no bypass at all.
- 30-minute tokens with a
primitiveBypass: trueclaim, re-checked per request against the whitelist — removing a base immediately revokes its derived sessions. - Member scope only.
+primitivetest*cannot hold admin/owner privileges or receive invitations to those roles — boundary calls returnRESERVED_EMAIL_FOR_ADMIN. Admin-only paths, like sending a notification withclient.notifications.send(), still need a real admin sign-in. - Suffix shape. Derived addresses match
<base-local>+primitivetest<suffix>@<base-domain>where the suffix is[A-Za-z0-9._-]*; only single-+shapes are accepted.
Use this for automated tests and local development — not for staging or production flows.
Invite-Only Apps
Because the signup gates apply, a fresh derived address on an invite-only app is stopped at the email step — waitlisted or rejected before any code is accepted. Rather than routing invitation emails through CI, add the member directly:
# 1. One-time app setup: add ci@example.com to testAccountBaseEmails in
# app.toml, then apply it
primitive config push --only app
# 2. Per test user: add the derived address as a member — no invitation email.
# --json returns the new member's userId for scripting.
primitive users create ci+primitivetest-checkout@example.com --json
# 3. The test session signs in through the normal OTP flow with code 000000users create adds the membership directly, so the invite gate never fires and the OTP sign-in proceeds as an existing member. For features gated on resource metadata — a subscription entitlement, a feature flag — an app owner or admin can set the test user's state directly, since app-level owners and admins bypass category write rules:
primitive metadata set user <userId> entitlements --data '{"tier":"pro"}'With those steps a headless or browser-automation session can sign in as a fresh, entitled member with no human interaction.
How It Works Under the Hood
For reference, here's what the starter templates handle for you:
- Token management — Access tokens are short-lived and refreshed automatically in the background. Refresh tokens are stored securely.
- Auth state — The client emits
authStateChangedevents that the templates use to switch between login and app UI (the web template's router guard;AuthGateViewon iOS). - Logout — Calling
client.auth.logout()clears tokens, closes documents, and fires the auth state event. Both templates include a logout button (PrimitiveProfileViewon iOS). - Safari compatibility — The template's production deployment includes a first-party refresh proxy for Safari's strict cookie policies.
Using the Client Directly
The templates are optional. Everything they do runs on public client APIs, so any web framework (React, Svelte, vanilla JS) or any Swift app can implement the same flows directly. Install the client — pnpm add js-bao-wss-client, or add the Swift package — and initialize it:
const client = await initializeClient({
apiUrl: "https://primitiveapi.com",
wsUrl: "wss://primitiveapi.com",
appId: "YOUR_APP_ID",
});
// A persisted session (if any) has been restored by this point.
if (client.isAuthenticated()) {
const userId = await client.waitForUserId();
console.log("signed in as", userId);
}let client = JsBaoClient(options: JsBaoClientOptions(
apiUrl: "https://primitiveapi.com",
wsUrl: "wss://primitiveapi.com",
appId: "YOUR_APP_ID"
))
// Wait for the bootstrap to restore a persisted session (if any).
try await client.waitForAuthBootstrap()
if client.isAuthenticated() {
let userId = try await client.waitForUserId(timeout: 5)
print("signed in as \(userId)")
}Discover which sign-in methods are enabled before rendering any UI:
const config = await client.getAuthConfig();
// {
// appId, name, mode, waitlistEnabled,
// googleOAuthEnabled,
// googleClients: { clients: { web: { clientId, redirectUris, usable }, … } },
// passkeyEnabled, passkeyRpConfig, hasPasskey,
// appleSignInEnabled, hasApple, emailSignInEnabled
// }
const methods = {
// Google registers a client per platform, so availability is the provider
// being enabled AND this platform's entry being usable. `checkOAuthAvailable()`
// computes the same thing; sharing one predicate is what keeps a rendered
// button clickable.
google: googleWebClientAvailable(config),
// ONE email capability: one request sends one email carrying both a code
// and (when a link can be issued) a link, so there is no method to offer.
email: config.emailSignInEnabled,
passkey: config.hasPasskey,
};let config = try await client.auth.getAuthConfig()
// AuthConfigInfo: appId, name, mode, waitlistEnabled,
// googleOAuthEnabled,
// googleClients: { clients: ["ios": (clientId, redirectUris, usable), …] },
// passkeyEnabled, passkeyRpConfig, hasPasskey,
// appleSignInEnabled, hasApple, emailSignInEnabled
let methods = (
// Google registers a client per platform: this is the provider being
// enabled AND this app's `ios` entry being usable, in one property so the
// button and the flow cannot disagree.
google: config.googleSignInAvailable,
apple: config.hasApple,
// ONE email capability: one request sends one email carrying both a code
// and (when a link can be issued) a link, so there is no method to offer.
email: config.emailSignInEnabled,
passkey: config.hasPasskey
)Google OAuth — start the flow, then handle the callback (?code=&state=) on your redirect route. On iOS, present the URL in an ASWebAuthenticationSession (the iOS template's PrimitiveAuthManager.startOAuth() is a complete reference implementation):
// True when Google OAuth is enabled AND the `web` client entry is usable.
const googleAvailable = await client.checkOAuthAvailable();
if (googleAvailable) {
// Redirects the browser to Google. Code after this does not run on success.
await client.startOAuthFlow(continueUrl);
}
// On the callback route (?code=&state=): token is stored, WS reconnects.
await client.handleOAuthCallback(code, state);// True when Google OAuth is enabled AND the `ios` client entry is usable.
let googleAvailable = await client.checkOAuthAvailable()
if googleAvailable {
// Open this URL in a browser / ASWebAuthenticationSession.
let authUrl = try await client.startOAuthFlow(
redirectUri: redirectUri,
continueUrl: continueUrl
)
_ = authUrl
}
// On the callback (?code=&state=): token is stored, WS reconnects.
try await client.handleOAuthCallback(code: code, state: state)On iOS, signInWithGoogle and signInWithApple wrap the whole flow in a single call — they present the system auth sheet, run the redirect and code exchange, apply the session token, and reconnect the WebSocket. Each returns the signed-in userId and an isNewUser flag. Google derives its redirect URI from the bundled GoogleService-Info.plist (or pass redirectUri: explicitly); Apple uses the app's "Sign in with Apple" entitlement and the server's configured Apple audiences. Read hasApple from the auth config to decide whether to show the Apple button.
Both helpers also take an optional inviteToken, so a pending invitation resolves atomically as part of sign-in — no follow-up invitations.accept call needed:
let google = try await client.signInWithGoogle(inviteToken: tokenFromEmail)
// google.userId, google.isNewUser
let apple = try await client.signInWithApple(inviteToken: tokenFromEmail)
// apple.userId, apple.isNewUserEmail sign-in — one request, then finish with either credential from the one email:
// `redirectUri` is optional and defaults to the client's oauthRedirectUri.
// With no target at all the email carries the code alone, rendered from the
// same template. A target that IS sent must match the app's non-empty
// `emailRedirectUris`, or the request is rejected 400 `Invalid redirect
// URI` — nothing degrades to code-only on your behalf.
await client.emailSignInRequest(email, {
redirectUri: "https://app.example.com/auth/magic-callback",
});
// Finish by typing the code from the email...
const { user, isNewUser } = await client.otpVerify(email, code);
// ...or by clicking the link in the same email, which lands on the callback
// page with ?magic_token=... — see the magic-link callback example. Either
// one retires both: one email signs the user in once.// `redirectUri` is optional, and OMITTING it is how a code-only email is
// requested: the server renders one from the same template and consults no
// allow-list. A target that IS supplied must match the app's non-empty
// `emailRedirectUris`, or the request is rejected 400 `Invalid redirect
// URI` — nothing degrades to code-only on your behalf.
_ = try await client.auth.emailSignInRequest(
email: email,
redirectUri: "myapp://auth/magic-link"
)
// Finish by typing the code from the email...
let result = try await client.auth.otpVerify(email: email, code: code)
// ...or by opening the link in the same email, which arrives as a
// `.magicLink(token:purpose:)` link target. Either one retires both:
// one email signs the user in once.
let user = result.user
let isNewUser = result.isNewUser ?? falseemailSignInRequest sends the email; otpVerify(email, code) finishes with the code and magicLinkVerify(token) with the link.
Inspect session state and gate work on auth being ready:
const signedIn = client.isAuthenticated(); // boolean
// Wait until a userId is available. Default timeout 5000ms.
const userId = await client.waitForUserId({ timeoutMs: 5000 });
// Wait until authenticated AND offline DBs are ready. Returns the mode.
const ready = await client.waitForAuthReady({ timeoutMs: 6000 });let signedIn = client.isAuthenticated() // Bool
// Wait for a userId, including one from a sign-in that starts after the call.
let userId = try await client.waitForUserId(timeout: 5)
// Wait until signed in. Returns the userId and the network mode.
let ready = try await client.waitForAuthReady(timeout: 6)And sign out:
await client.logout({
wipeLocal: true, // delete locally cached document data + KV cache
waitForDisconnect: true, // wait for the WS to close before resolving
});
// Fires `auth:logout` immediately and `auth:logout:complete` when finished.try await client.auth.logout(options: LogoutOptions(
wipeLocal: true, // delete locally cached document data + KV cache
waitForDisconnect: true // wait for the WS to close before resolving
))When sign-in is refused
A refused sign-in fails with an auth error whose code says why, so branch on the code rather than the message:
INVITATION_REQUIRED— the app is invite-only and this address has no invitation.DOMAIN_NOT_ALLOWED— the address is outside the app's allowed domains.ADDED_TO_WAITLIST— the app runs a waitlist and the address was added to it.OTP_MAX_ATTEMPTS— too many wrong codes for this sign-in; request a new email.RATE_LIMITED— too many requests; wait and retry.PASSKEY_USER_VERIFICATION_FAILED— the passkey provider did not verify the user; retrying and completing Face ID or the PIN fixes it.
A bad invitation token — invalid, expired, or already redeemed — always answers INVITE_TOKEN_INVALID; see Invitations.
Next Steps
- Users and Groups — Organize users and manage access control
- Invitations — Access modes, invitations, and how email-based shares resolve
- Overview — See how auth fits into the platform