# Authentication

> Application identity: magic link, Google, Apple, and email/password — declared in the project, verified by the runtime.

[HTML version](https://snoozestack.com/docs/authentication/) · [Docs index](https://snoozestack.com/llms.txt)

Every project gets application identity — sign-in for *your app's own users* — with four methods: **magic link** (on by default), **email/password**, and **Google** and **Apple** sign-in. What the project offers is declared in `project.toml`'s `[auth]` section; hosted, the sign-in flows and session verification both run inside your project's [runtime](https://snoozestack.com/docs/runtime.md) — locally, per request, no round trip — before your [function](https://snoozestack.com/docs/functions.md) sees it as `capabilities.auth.user`.

| Surface | Availability |
| --- | --- |
| Portal UI | **Users** (delete/ban) and **Providers** (declared methods + the one callback URL to register) — configuration itself is files |
| CLI | `snoozestack publish` sends the declared config (a preview runs the checkout's); `auth pull` writes the email/page templates out as files |
| SDK / HTTP | `snoozestack.identity.*` — magic link, password, OAuth, id-token, sessions, sign-out |

**Diagram:** Client · magic link · OAuth · password · sign in · Runtime identity · <ref>…/auth/… · auth.db · session token · Client session · sent with each call · Runtime verifies locally · signature + session row, no round trip · capabilities.auth.user · { id, email } in your function · same process · Sign-in, refresh, verification and revocation all run in the project's runtime; a revoked session fails on its next request.

**Why use it**

Authentication is the classic thing not to build yourself. Not because the happy path is hard — checking a password hash is twenty lines — but because the rest isn't: secure password storage, session expiry and refresh, reset tokens that can't be replayed, OAuth callback handling, email delivery. Each is a well-known place to get security subtly wrong, and none of it is what makes your product worth using.

**Your runtime owns identity.** Each project keeps its users, password hashes, sessions and signing keys in a separate `auth.db`, replicated alongside the application database. Sign-in, refresh and verification run in that container; revoking a session takes effect on its next request. OAuth still uses the stable callback on your project host. The portal relays email and the console's user management requests.

The identity database stays separate from your schema, SQL editor and database exports. Put profile fields in your own table keyed by `capabilities.auth.user.id`. Local development and previews keep a built-in `user@test.com` / `1234` account; production never has it.

- **Passwordless by default** — Magic link is enabled out of the box: a successful sign-in proves the address and creates the user in one flow — nothing to configure and no password to reset. It doubles as the reset flow when you add passwords later.
- **Ordinary email/password** — `signUpWithPassword()` / `signInWithPassword()`. Turn on `confirm_signups` if you need verified addresses; leave it off while prototyping so sign-ups return a session immediately.
- **Sign in with Google / Apple** — Lower friction, and Apple is required by App Store review once an iOS app offers another social login. Each needs a one-time registration in the provider's console — walked through below. Native apps skip the browser entirely with `signInWithIdToken`.
- **Per-user data without trusting the client** — A function with `auth = "required"` binds `capabilities.auth.user.id` into its own queries — the client never sends, and cannot forge, its identity. See [Permissions](https://snoozestack.com/docs/permissions.md).

### Declaring it

```bash title="snoozestack/project.toml"
[auth]
redirect_urls = ["https://myapp.example.com/welcome", "myapp://callback"]
confirm_signups = true           # off by default: on, password sign-ups must confirm by email

[[auth.providers]]
name = "google"
client_id = "1234-abc.apps.googleusercontent.com"
secret_name = "GOOGLE_OAUTH_SECRET"          # a declared [[secrets]] entry

[[auth.providers]]
name = "apple"
client_id = "com.example.app.signin"         # the Services ID
secret_name = "APPLE_SIGNIN_KEY"             # the .p8 key's contents
team_id = "ABCDE12345"
key_id = "XYZ9876543"
native_client_ids = ["com.example.app"]      # iOS bundle ids for id-token sign-in
```

`client_id` is public by design (it rides in every browser redirect); `secret_name` is a *reference* to a declared secret — the value is set with `snoozestack secrets set`, never committed. `redirect_urls` is the allowlist of where sign-in may send a visitor afterwards: your project's own URLs are allowed automatically; add your app's domains, deep links, and localhost here. `snoozestack publish` sends the declaration up, and a preview runs the checkout's own. A preview never needs its URL declared: inside a preview each declared URL's path is moved onto the preview's own address, which is allowed too, and production's list stays exactly as committed. A request from a preview page is answered by that preview even when your code names production's API base, so testing a preview never signs anyone up in production. Provider registrations are **per project** — your users see your app's name on the consent screen, and no other tenant shares your registration.

### Sessions, in your functions

```bash title="app code"
await snoozestack.identity.requestMagicLink({ email, redirectTo: "https://myapp.example.com/welcome" });
// … user clicks the emailed link …
const { user } = await snoozestack.identity.completeMagicLink();

// From here every functions.invoke() carries the session automatically.
const { data } = await snoozestack.functions.invoke("my-notes");
```

Inside the function, the runtime has already verified the token's signature before your code runs: `capabilities.auth.user` is `{ id, email }` (or `null` under `auth = "optional"` with no session). Sessions are short-lived and refresh automatically in the SDK; `identity.onStateChange()` observes `anonymous | pending | authenticated | expired` reactively.

### Local development: the same plane, on your machine

Under `snoozestack dev`, the runtime serves the project's own `/auth/…` routes from the same module the hosted container runs, backed by `.snoozestack/auth.db` on your machine — users, passwords, magic links, OAuth state, sessions with immediate revocation, rate limits, self-delete, and the push device registry, all real. It comes seeded with one account, `user@test.com` / `1234` (id `d0000000-0000-4000-8000-000000000001`) — a real address, so an `<input type="email">` accepts it — and any email can sign up beside it. A preview's URL has the same account, so one login works on both. Only the parts that need the outside world are simulated, at the provider boundary: the Google and Apple buttons open a local “choose an account” page instead of the provider (the seeded account is one click; a new email creates a user; Cancel is the provider's `access_denied`) and then complete the real callback, so `auth.db` gains the same state and subject rows it would hosted. A native app posts an id_token minted by the dev server itself (`GET /_dev/oauth/<provider>/id-token?email=…`) or the shorthand `dev:<email>`. Email is rendered with your own templates and written to the dashboard's Emails page instead of sent, so a sign-up or magic link completes from there. Nothing leaves your machine, and the dev server logs each simulated hop.

Hosted's refusals happen locally too. Every `/auth/…`, `/push/…` and `/signals/v1/…` request needs the project's anon key; locally that is a stable `sb_local_…` key derived from the project name, printed at startup and shown on the Overview, so an app that never sends a key fails on your laptop rather than after publishing. The same per-route rate limits apply. Both can be turned off in `snoozestack/dev.toml` (`[dev] api_key = false`, `rate_limits = false`) for load scripts. Two dev-only routes let a browser or integration test begin clean and check what happened; neither exists on any hosted project, custom domain or preview:

```bash title="terminal"
curl http://localhost:8787/_dev/auth                 # { mocked: true, apikey, users, seeded_user, last_sign_in: { provider, at } | null, sessions, pending_oauth, outbox }
curl -X POST http://localhost:8787/_dev/auth/reset   # every user, session, device and outbox message gone; the seeded account is back
snoozestack dev reset                                # also removes auth.db and the outboxes on disk; the next start re-seeds
```

### Email & page templates are files

The three templates a project owns — the confirm-signup email, the sign-in-link (magic link) email, and the reset-password page — live in the repo, so they diff, review, and roll back like everything else:

```bash title="terminal"
snoozestack auth pull      # writes the current templates into the project
# snoozestack/authentication/email/confirm-signup.html
# snoozestack/authentication/email/sign-in-link.html
# snoozestack/authentication/page/reset-password.html
```

The two emails carry their subject on the first line as an HTML comment (`<!-- subject: Confirm your {{ .ProjectName }} account -->`), so the file stays a fragment you can open in a browser. `snoozestack publish` sends whichever exist with the release; the console shows them read-only. There is no template editor UI, on purpose: the files are the one copy.

### Sign in with Google

A one-time setup in the Google Cloud console, about ten minutes. You are creating an OAuth client at Google, then declaring its credentials in the project. The callback URL to register is shown on the console's **Authentication → Providers** page: `https://<ref>.snzzz.com/auth/oauth/google/callback` — there is exactly one, and it must be registered character-for-character.

1. **Create or pick a Google Cloud project** at [console.cloud.google.com](https://console.cloud.google.com) — the project dropdown in the top bar → **New Project**.
1. **Configure the OAuth consent screen.** Under **APIs & Services → OAuth consent screen** (newer consoles: **Google Auth Platform → Branding**), click **Get started**, fill in the app name and support email, pick **External** as the audience (or **Internal** on Workspace for org-only sign-in), and **Create**.
1. **Add the scopes under Data Access**: `openid`, `…/auth/userinfo.email`, and `…/auth/userinfo.profile` — then **Update** and **Save**.
1. **Create the OAuth client** on the **Clients** tab: application type **Web application**, with your project's callback URL under **Authorized redirect URIs**. Save the client ID and secret — the secret can't be viewed again. (An iOS app additionally gets its own **iOS**-type client — client ID only — for native sign-in, below.)
1. **Declare it in the project.** Add the `[[auth.providers]]` entry with the web client's id, set the secret, and publish:

```bash title="terminal"
snoozestack secrets set GOOGLE_OAUTH_SECRET=GOCSPX-…
snoozestack publish
```

1. **Add yourself as a test user** under **Audience → Test users** — while the Google app is in **Testing**, only listed accounts can complete sign-in.
1. **Test the flow** from your app:

```bash title="sign in with Google"
snoozestack.identity.startOAuth({
  provider: "google",
  redirectTo: "https://myapp.example.com/welcome",   // must be on redirect_urls
});
// … back on that page:
const { user } = await snoozestack.identity.completeOAuthCallback();
```

1. **Publish the Google app** (Audience → **Publish app**) — with only these three non-sensitive scopes it goes live immediately, and any Google account can sign in from then on.

| Error | Cause |
| --- | --- |
| `redirect_uri_mismatch` | What you registered when creating the OAuth client isn't character-for-character the project's callback URL. |
| `access_blocked` | The consent screen is still in **Testing** and the account isn't on its test-user list — add it under **Audience**, or publish the app. |

### Sign in with Apple

Same shape as Google with one difference worth knowing up front: Apple issues no static client secret. The secret is a token signed with a `.p8` key you download once — the runtime signs it for you from the four values the provider entry declares (Services ID, Team ID, Key ID, and the key itself). Everything on Apple's side happens under [**Certificates, Identifiers & Profiles**](https://developer.apple.com/account/resources/identifiers/list):

| Value | Where it comes from | Lands in |
| --- | --- | --- |
| **App ID / Bundle ID** | Identifiers → an **App IDs** entry with Sign in with Apple ticked | `native_client_ids`, for a native app |
| **Services ID** | Identifiers → a **Services IDs** entry (convention: `com.example.app.signin`) | `client_id` |
| **Team ID** | Top right of the developer account | `team_id` |
| **Key ID + .p8 key** | Keys → a **Sign in with Apple** key — downloadable exactly once | `key_id`, and the key contents via `snoozestack secrets set` |

1. **Register the Services ID and point it at your project.** Open the services ID, tick **Sign in with Apple** → **Configure**: primary App ID from above; **Domains** is `<ref>.snzzz.com` (host only); **Return URLs** is the callback from the console's Providers page — `https://<ref>.snzzz.com/auth/oauth/apple/callback`. The final **Save** on the identifier page is easy to miss, and without it the configuration is discarded.
Skipping the Return URL is the most common way Apple sign-in fails, and the error says so only if you know the phrasing: Apple's page answers `"Invalid web redirect url."` with `errorCode: invalid_request` rather than naming the URL. A native app signing in with an id_token needs no Return URL at all — only the browser redirect flow does.
1. **Create the signing key** under **Keys**: tick **Sign in with Apple**, configure it against the same App ID, register, note the **Key ID**, and download `AuthKey_<keyid>.p8` — **Apple lets you download it exactly once**; a lost key can only be revoked and replaced.
1. **Declare it and publish:**

```bash title="terminal"
snoozestack secrets set APPLE_SIGNIN_KEY="$(cat AuthKey_XYZ9876543.p8)"
snoozestack publish
```

1. **Test the flow.** On the web this is the ordinary redirect (`startOAuth({ provider: "apple" })`); on iOS, pass the credential from `ASAuthorizationController` straight to an id-token sign-in so the user never leaves the app:

```swift title="iOS (Swift)"
let result = try await snoozestack.identity.signInWithIdToken(
  provider: "apple", idToken: idToken, nonce: nonce
)
```

On iOS, prefer `signInWithIdTokenResolvingPrivateRelay` over plain `signInWithIdToken` when users can pick **Hide My Email**: Apple puts the relay address in the identity token on every sign-in but drops it from the credential object after the first authorization, so a user whose record was created without it comes back with `user.email == nil` from then on. The variant reads the token's claim when the session has none and remembers it on the device.

| Error | Cause |
| --- | --- |
| `invalid_client` | The declared Services ID, Team ID, Key ID, and key don't agree — re-check the four values, then publish again. |
| `invalid_request` | The Return URL isn't registered character-for-character on the services ID, or its domain hasn't been verified by Apple. |
| `bad ID token from a native app` | The app's bundle ID isn't in `native_client_ids`, or the nonce it was issued with isn't the one passed to `signInWithIdToken`. |

### Native id-token sign-in (Google too)

Both Google and Apple mint an id_token a native app can present directly to `POST /auth/oauth/<provider>/id-token` — no browser round trip, no redirect URL. Declare the native client id (Google: the iOS client's id; Apple: the bundle ID) in `native_client_ids`, and use the SDKs' `signInWithIdToken`. The response includes `isNewUser`, for routing first-time sign-ins to onboarding.

### Users

The console's **Authentication → Users** page lists the project's signed-up users with the sign-in method each account used, and can **ban/unban** or **delete** one. Accounts are created by people signing up — there is deliberately no “add a user with a password you typed” button. Each namespace has its own user population: a preview's users are not live's.

### HTTP API (/api/auth)

```bash title="application identity — on the project's own host"
# Request a magic link
curl -X POST "https://<ref>.snzzz.com/api/auth/magic-link" \
  -H "Content-Type: application/json" \
  -d '{"email": "me@example.com", "redirect_to": "https://myapp.example.com/welcome"}'

# Password sign-in → { session_token, refresh_token, user }
curl -X POST "https://<ref>.snzzz.com/api/auth/password/sign-in" \
  -H "Content-Type: application/json" \
  -d '{"email": "me@example.com", "password": "secret123"}'

# The project's public signing keys — verify a session token in your own backend
curl "https://<ref>.snzzz.com/api/auth/jwks"
```

Also under `/api/auth`: `password/sign-up`, `password/change`, `magic-link/verify`, `session` (refresh), `sign-out`, `delete` (a signed-in user removes their own account — remove the app's own data about them first), `oauth/<provider>/start|callback|id-token`, and the hosted `reset` page. Password reset needs no second system — the reset flow *is* the magic link. The provider *callback* URLs registered at Google and Apple are the one exception without the `/api` prefix (`…snoozestack.com/auth/oauth/<provider>/callback`) — deliberately, since a registration must keep resolving permanently. See [snoozestack-js](https://snoozestack.com/docs/sdk-js.md) for the same operations through `snoozestack.identity`.
