Docs.

Passkeys + session policy

archive/passkeys-design-history.md

ARCHIVED. The passkeys-as-staff-door design; staff auth is Google-only since 0.9.0 and passkeys are a customer-optional convenience — current auth lives in ../architecture/auth.md. Nothing here is current — see docs/README.md for the live map.

Implementation map for the customer-optional passkey support and the rolling staff session.

Status: current as of 2026-06-07. Two things to read this doc against:

  • Staff sign in with Google OAuth. Passkeys are not a staff requirement; the staff passkey requirement and its enrol wall (/auth/enroll-passkey) were built and then removed — that route no longer exists. Google is the only staff path.
  • Passkeys are a customer-only convenience — optional, never required. Customers also keep magic links. signInWithPasskey() is a secondary button on the sign-in page, surfaced only when this browser already holds a passkey (src/lib/auth/passkey-hint.ts). PasskeyManager (src/components/PasskeyManager.tsx) lives in the portal (/portal) for add/remove.

The rolling staff session below is still in force — but it's keyed purely on the verified JWT session_id and applies to any office session (now Google staff sessions), with no passkey/amr/aal assurance check. The office gate is office role + live rolling session, no auth-method check, so a customer promoted to staff while signed in keeps office access until that session idles (15 min) / hits the 12 h cap — revoke sessions to cut it immediately. Role management lives at /office/team.

Everything from "What each method does", "Decisions (locked)", the staff lifecycle, and the passkey-derived assurance signal is superseded design history for the brief period when passkeys were the staff door; it is retained below, clearly marked, because it explains the shape of the session table and the ceremonies that are still live. Genuinely-current content: the route map, the staff_sessions table, the idle/absolute timeouts, and the customer passkey UX.

Build-time pivot (2026-06-05): the installed Supabase stack (@supabase/auth-js@2.106.1, GoTrue with auth.webauthn_credentials / auth.webauthn_challenges) has native passkeys — primary auth.signInWithPasskey() (usernameless one-tap), auth.registerPasskey(), and auth.passkey.* / auth.admin.passkey.*, behind the experimental flag auth.experimental.passkey: true. We use the native API instead of hand-rolling WebAuthn. That drops the @simplewebauthn dependency, the custom webauthn_credentials table, the challenge cookie, and the generateLink → verifyOtp session bridge — GoTrue owns the ceremony, credential storage, and session issuance. The original hand-rolled design is kept below struck-through context where it helps; the Native implementation section is the source of truth.

Why passkeys at all

  • Phishing resistance. Magic links are phishable/relayable. A passkey is bound to the origin (studio.chat) and signs nothing for a look-alike domain.
  • Better UX. One biometric tap beats round-tripping an email — especially with conditional-UI autofill.

These are why we keep passkeys as a customer convenience. (The phishing argument originally motivated requiring passkeys for staff; that requirement was dropped in favour of Google OAuth — Workspace-managed, centrally 2FA-able — as the staff door. See the office auth docs, docs/architecture/auth.md.)

Superseded design history (passkeys-as-staff-door)

Everything in this section described the brief design where a passkey was a hard staff requirement with a forced enrol wall and a passkey-derived session assurance. None of it is current — staff use Google OAuth, the enrol wall (/auth/enroll-passkey) is deleted, and the gate no longer reads amr/aal. Kept for context on the still-live staff_sessions table and the native passkey ceremonies that customers still use.

Decisions (locked) — historical

#Decision
ScopePasskeys for both office and portal.
Staff requirementHard requirement — no office access without a passkey. A roaming security key (YubiKey) is the universal fallback for locked-down/incapable devices. A staff member with no passkey-capable authenticator at all is locked out by design.
Customer requirementOptional. Magic link stays their standing sign-in; passkeys are a "faster next time" nicety.
Magic linkKept, but its role differs by audience (below).
Enrolment triggerRequired wall for office (you cannot reach /office without enrolling/using a passkey). Opt-in prompt for portal customers.
PasswordsRemoved entirelysignInWithPassword, sendPasswordReset, the password/reset form modes, /office/set-password, and SetPasswordForm all go away. Magic link is the new "reset."
Assurance storeServer-side session record (opaque pointer cookie), not a signed stateless cookie — revocable, auditable, and required for the rolling timeout.
Session policyPasskey assurance is per-session. Staff sessions are rolling: idle beyond a limit ⇒ re-auth (a passkey tap, not a full magic-link). Optional absolute cap. Customers keep normal Supabase session longevity.
PathsAll auth ceremonies namespaced under /auth/* (not /account — see below).

What each method did, per audience — historical

CustomersStaff
Magic linkFull standing sign-inBootstrap + recovery only — got you to the enrol wall, never to /office on its own
PasskeyOptional, fasterRequired for any office access

Historical staff lifecycle (no longer applies — staff use Google):

  1. First time / post-recovery: magic link → forced "set up a passkey" wall → enrol (biometric) → /office.
  2. Normal: tap the passkey → /office.
  3. New device: magic link → wall → enrol a passkey here, or hybrid/QR.

Current reality: staff = Google OAuth only; magic links are declined for any email holding an office role (src/app/auth/sign-in/actions.tsemailIsStaff). Customers: magic link is the standing sign-in, with an optional passkey on top.

Paths: /auth/* (why not /account)

The distinction is ceremony vs. home:

  • Ceremonies (sign-in, OAuth/magic-link callback, the customer passkey landing, sign-out) happen before/around having a session → live under /auth/*.
  • Account home (manage passkeys, profile) is the authenticated self-service area → /portal for customers, settings inside /office for staff. /account would be a fine name for that, but /account/sign-in is self-contradictory (you sign in to get an account context).

Route map (current):

  • /auth/sign-in — the unified sign-in for everyone (office + portal). Google OAuth, a customer magic link, or a customer passkey. The old /office/sign-in and /portal/login are permanent redirects here.
  • /auth/callback — Google PKCE / magic-link landing; routes by audience (office role → /office, else → /portal, auto-creating a customer account).
  • /auth/continue — the customer passkey landing (completes signInWithPasskey).
  • ~~/auth/enroll-passkey~~deleted. It was the staff enrol wall; it no longer exists now that staff use Google.
  • Sign-out stays a server action (it also revokes the rolling staff-session row).
  • Implemented under src/app/auth/* sharing the dark sign-in layout (src/app/auth/layout.tsx).

Passkey management (add / remove) is PasskeyManager (src/components/PasskeyManager.tsx), surfaced in the customer portal (/portal). There is no staff passkey panel — staff don't use passkeys.

Native implementation (source of truth)

What GoTrue gives us (no custom code)

  • Credential + challenge storage: auth.webauthn_credentials / auth.webauthn_challenges in the Supabase-managed auth schema. We never create or touch these directly.
  • Sign-in: supabase.auth.signInWithPasskey() — usernameless / discoverable, runs the full navigator.credentials.get() ceremony and issues a real session. This is the optional one-tap customer login (a secondary button on the sign-in page; not used by staff).
  • Enrolment: supabase.auth.registerPasskey() — registers a passkey for the currently authenticated user (a signed-in customer adds one from the portal via PasskeyManager).
  • Management: supabase.auth.passkey.* (+ admin.passkey.*) to list / remove credentials.
  • Capability check: browserSupportsWebAuthn() from the SDK gates the UI.

All passkey methods require the experimental flag — the client is created with auth: { experimental: { passkey: true } }. Calling them without it throws.

Client topology

Passkey ceremonies call navigator.credentials, so they must run in the browser. New src/lib/auth/supabase-browser.ts builds a createBrowserClient (@supabase/ssr) with the experimental flag; it syncs the session into the same cookies the SSR server client already reads, so the existing getSupabaseServerClient() / office + portal gates see the session with no bridge. Magic-link stays a server action exactly as today.

Server config (supabase/config.toml)

Uncomment + set, then restart Supabase:

[auth.passkey]
enabled = true
[auth.webauthn]
rp_display_name = "studio.chat"
rp_id = "localhost"
rp_origins = ["http://localhost:3026"]
  • rp_id / rp_origins are the relying-party binding. Local uses localhost (passkeys work there); a 127.0.0.1 origin would need rp_id = "127.0.0.1", so we standardize office/passkey testing on http://localhost:3026.
  • Prod sets rp_id = "studio.chat", rp_origins = ["https://studio.chat"] in the Supabase dashboard (not this file) — tracked alongside the redirect allowlist. The Cloudflare tunnel (*.trycloudflare.com) and per-deploy preview hashes bind passkeys to ephemeral domains → useless; this only affects the customer passkey convenience (staff use Google), and the office dev-bypass remains the local office shortcut.

Assurance signal (how the gate knows a session used a passkey) — removed

Superseded. This passkey-derived assurance check is gone. The gate no longer reads amr / aal to tell a passkey session from a magic-link one; there's no auth-method check at all. getSessionAssurance() (src/lib/auth/staff-session.ts) now returns only the verified JWT session_id — the key for the rolling-session record. The historical design read supabase.auth.getClaims() amr/aal and considered an MFA-factor (AAL2) fallback; neither is used now.

Staff session record (the only custom table — still current)

GoTrue has no per-domain idle policy, and we want the timeout staff-only (customers keep normal longevity), so we keep one lean table. This is live and unchanged — only what it's keyed against shifted (any office session, not a passkey one):

staff_sessions (RLS deny-all, service-role only):

columnnotes
session_id text pkGoTrue session id from the JWT session_id claim
account_id uuid → accounts(id)for admin "sign out everywhere"
created_at timestamptzabsolute-cap anchor
last_activity_at timestamptzrolling-idle anchor
revoked_at timestamptzsign-out / admin kill

No passkey_verified column — there is no assurance flag. The row keys off the JWT session_id (trustworthy), so it can't be forged from the client.

Rolling-timeout enforcement (office gate) — current

Single chokepoint: src/lib/office/auth.ts (getOfficeAccess), backed by evaluateStaffSession() in src/lib/auth/staff-session.ts. For non-dev-bypass sessions:

  • The gate requires a valid Supabase session + an office role + a live, non-idle, non-revoked staff_sessions row → office user. No auth-method check — a Google staff session is the normal case. There is no enrol wall and no redirect to /auth/enroll-passkey (that route is gone).
  • evaluateStaffSession() lazily creates the row on the session's first office request, then enforces the absolute cap + idle limit and touches last_activity_at, throttled to ≥60s so a busy session isn't a write per request.
  • Idle limit (OFFICE_IDLE_LIMIT_MIN, default 15 min): now − last_activity > limit ⇒ state idle ⇒ the gate bounces to a re-auth (now a Google re-auth, not a passkey tap). Absolute cap (OFFICE_SESSION_MAX_HOURS, default 12 h): now − created_at > cap ⇒ state expired ⇒ full fresh sign-in.
  • Sign-out / revoke sets revoked_at (revokeStaffSession); the sidebar sign-out calls supabase.auth.signOut(). revokeAllStaffSessions() is the "sign out everywhere" / admin-kill path.
  • Dev-bypass stays exempt. Customers: no rolling timeout, no requirement.

Rollout

  • Staff bootstrap is a Google sign-in — no passkey, no enrol step. The "provision brandon as admin" note is "grant the role, then Google sign-in" (see docs/architecture/auth.md → Provisioning).
  • Prod Supabase redirect allowlist must include /auth/callback (tracked) and the Google provider must be enabled in the prod project; local Google is config-as-code ([auth.external.google] in supabase/config.toml).

What got removed (vs. the original password world)

  • signInWithPassword, sendPasswordReset, the password + reset form modes, /office/set-password, and SetPasswordForm — all gone.
  • The staff passkey requirement and the enrol wall (/auth/enroll-passkey) — built, then removed in the Google-OAuth pivot.
  • Still alive: the customer magic-link infra (src/app/api/auth/email/route.ts, src/lib/auth/email-templates.ts, the /office/emails preview), customer passkeys (PasskeyManager, signInWithPasskey), and the rolling staff session.

Verification

Real Chrome (chrome-devtools MCP) on localhost (passkeys work there) + Mailpit for magic links. Cover: staff Google sign-in → /office; staff magic link is declined (lands at the portal/customer path, never /office); idle timeout → re-auth; customer magic-link sign-in → /portal; customer passkey enrol + sign-in via PasskeyManager / signInWithPasskey; remove-passkey.