Scout — Full Product Context → feature documentation

Authentication, Account, Settings, and Profile

Scout runs its own self-hosted authentication engine (backend/src/auth/) — ES256-signed JWTs, a JWKS endpoint, Argon2id password hashing, and a Postgres-backed session store —…

Summary

Scout runs its own self-hosted authentication engine (backend/src/auth/) — ES256-signed JWTs, a JWKS endpoint, Argon2id password hashing, and a Postgres-backed session store — which replaced Supabase Auth in PR #95 (5ec5d340, "replace Supabase with self-hosted auth engine + self-managed Postgres"). Every session, including a guest's, is a real profiles row: there is no local-only or anonymous-device-only mode. Four session-creation paths exist server-side (email/password, Google OAuth, Apple OAuth, emailed 6-digit code, and device-scoped guest), but only three are reachable from the shipped production UI — Apple, Google, and the email code. The fourth, password sign-up/sign-in, is wired end-to-end but its form only renders in dev builds or under Maestro (showDevForm = __DEV__ || getIsMaestro(), mobile/app/auth/index.tsx:248); real users never see it. Guest ("Continue as guest") is fully built and is a real backend identity, but as of a 2026-08-27 change it is hidden behind a feature flag (guest_mode) whose code-defined default is off — see Status below, this is the single most consequential fact for marketing copy.

Account and Profile add a self-serve DELETE /api/account that deletes most per-user data but explicitly does not touch a user's community posts, comments, votes, or patch photos (see "What this feature does NOT do"). A public, opt-out web profile at /u/<slug> renders a shareable "credential" page with collection stats and patch art, generated on the fly, that 404s for guests and anyone who has turned it off (it's on by default).

Status (shipped / beta-badged / flagged off — name the flag and its default)

User-facing surfaces (screens, routes, scout:// deep links, entry points)

Mobile (each row registered in mobile/src/dev/deepLinkRoutes.ts):

Screen File Deep link
Sign-in landing mobile/app/auth/index.tsx scout://auth
Email code sign-in mobile/app/auth/email.tsx scout://auth/email
Onboarding — location mobile/app/onboarding-location.tsx scout://onboarding-location
Onboarding — notifications mobile/app/onboarding-notifications.tsx scout://onboarding-notifications
Onboarding — referral (out of scope) mobile/app/onboarding-referral.tsx scout://onboarding-referral
Location permission (re-ask from Settings) mobile/app/location-permission.tsx scout://location-permission
Profile mobile/app/(drawer)/profile.tsx scout://profile
Settings mobile/app/(drawer)/settings.tsx scout://settings
Blocked accounts (entry point only — the screen itself belongs to community and moderation) mobile/app/(drawer)/blocked-accounts.tsx scout://blocked-accounts

Web (no auth guard — intentionally open, public-profile.controller.ts:14-15):

How it works (the end-to-end mechanism: device → API → DB → response)

Token machinery

Sign-in methods

  1. Google / Apple OAuth (POST /api/auth/oauth/:provider, AuthEngineService.oauthSignIn, auth-engine.service.ts:262-355). The client sends a provider ID token; OAuthProviderVerifier (backend/src/auth/oauth/oauth-provider.verifier.ts) verifies it against the provider's own remote JWKS (Google https://www.googleapis.com/oauth2/v3/certs, Apple https://appleid.apple.com/auth/keys) with a configured audience allowlist (GOOGLE_OAUTH_CLIENT_IDS / APPLE_OAUTH_CLIENT_IDS). The engine then branches four ways: (1) an AuthIdentity already exists for that provider+account → sign into that Profile; (2) no identity, but the verified email matches an existing Profile → link the identity to it, only if the provider asserts the email is verified (an unverified-email match is rejected as an account-takeover vector, auth-engine.service.ts:305-313); (3) no identity, no email match, and the caller already holds an anonymous (guest) session → promote that guest Profile in place, same UUID; (4) otherwise → create a brand-new Profile + AuthIdentity.
  2. Email one-time code (POST /api/auth/magic-link/request then POST /api/auth/magic-link/verify, mobile/app/auth/email.tsx). Despite the route name, this is a 6-digit code, not a clickable link. Requesting a code creates the Profile immediately, at request time, not at verify time (magicLinkRequest, auth-engine.service.ts:374-392) — so an account exists the moment someone types an email, whether or not they ever enter the code. The code is a random 6-digit number, its SHA-256 hash stored, 15-minute TTL, and issuing a new code invalidates any prior outstanding code for that user+purpose (one-time-token.service.ts:32-38) so only the newest is ever valid. sendMagicLinkCode formats it as "123 456" specifically so iOS/macOS one-time-code autofill can offer it (auth-email.service.ts:27-29). AuthEngineService counts a verify as the actual "signup moment" only when the target Profile has never held a refresh token before (magic-link-verify, auth-engine.service.ts:420-431) — this is what drives an internal new-account alert without changing the client-visible isNewAccount flag.
  3. Guest (POST /api/auth/anonymous, AuthEngineService.anonymous, auth-engine.service.ts:196-238). Tapping "Continue as guest" calls this endpoint with the device's x-device-id header (Android ANDROID_ID, iOS IDFV, or a SecureStore UUID fallback — comment at auth-engine.service.ts:184) and finds-or-creates an anonymous Profile tied to that device. A partial unique index (profiles_device_id_anon_key, migration 20260804120000) enforces at most one anonymous profile per device — a concurrent double-launch that races the create is handled by catching the resulting P2002 and adopting the winner rather than failing (auth-engine.service.ts:222-236). A guest gets a generated readable handle like anon-marten-4821 as its displayName (backend/src/auth/anon-handle.ts), purely so the admin Users table can trace one guest across sessions — not guaranteed unique.
  4. Email/password (POST /api/auth/signup, POST /api/auth/signin) — see Status; dev/test-only in practice. Password hashing is Argon2id (backend/src/auth/password.service.ts).

Guest → member: the merge rule

The rule, stated in the code, is: guest data only follows the user into a member account on account CREATION, never on sign-in to a pre-existing account (doc comment, auth-engine.service.ts:250-259). But "creation" is implemented differently per method, and this is a real, non-obvious asymmetry:

On top of the server-side promote-in-place mechanism, the mobile client separately pushes anything that was only ever queued locally (not yet confirmed server-side) forward on a successful account-creating sign-in: afterIdentitySignIn in mobile/src/providers/AuthProvider.tsx:585-650 checks the response's isNewAccount flag; if true and the caller was a guest, it calls analytics.trackGuestConverted() and pushLocalGuestDataToServer(), which walks the locally pending patch/purchase queues and pushes them under the new account id, merging the result into the query cache and clearing only what the server confirmed (a failed push is deliberately left in the queue for retry — clearing it unconditionally was a prior data-loss bug, per the comment at AuthProvider.tsx:296-303). Purchases specifically also go through POST /purchases/migrate (backend/src/purchases/purchases.service.ts:87), which additionally calls RevenueCat to transfer entitlements from the device's anonymous RevenueCat id to the new account id.

One specific UI entry point defeats all of the above and always loses guest data, regardless of which method the guest then picks. The Profile screen's GuestBanner component (shown only to guests, mobile/app/(drawer)/profile.tsx:675) offers a "Sign up with email" button whose handler is await signOut(); router.replace('/auth'); (mobile/src/components/GuestBanner.tsx:14-17). AuthProvider.signOut (AuthProvider.tsx:823-868) revokes the guest's refresh token server-side and calls resetStore(), which wipes the entire local Zustand + MMKV store — including the pending-patch and pending-purchase queues that pushLocalGuestDataToServer would otherwise push forward — "everything goes unless it is deliberately re-established" (comment at AuthProvider.tsx:848-865). By the time the guest lands back on /auth and picks Apple, Google, or email, isGuest is already false and there is no access token to send as currentAccessToken, so the backend never receives a currentUserId and the promote-in-place branch can't fire for any method — including Google/Apple, which otherwise do preserve guest data. Any patches or purchases already confirmed server-side under that guest's (now signed-out but not deleted) Profile row become permanently unreachable — there is no way to sign back into a specific guest Profile once its session is gone. This is a real gap between what the merge-rule code guarantees and what this specific button in the product actually does.

Separately, POST /api/sync/reconcile-device (sync.controller.ts:60, sync.service.ts:757-) lets a still-anonymous caller merge forward any other stale anonymous Profiles found for the same device id (e.g. left over from the partial-unique-index race) — copying their patches/purchases/trips into the current guest profile and deleting the stale ones, atomically per profile. It is guarded to only run when the caller itself is anonymous, so an authenticated member can never use a spoofed device id to absorb someone else's guest data (sync.service.ts:765-772).

Account deletion

DELETE /api/accountAccountService.deleteAccount (backend/src/account/account.service.ts:29-64), guarded by JwtAuthGuard. Order matters and is enforced: (1) confirm the Profile exists; (2) delete the user's cloud-album S3 objects via AlbumDataService.deleteAll before touching Postgres rows — if S3 cleanup fails, the whole deletion aborts rather than leaving orphaned rows with orphaned bytes; (3) run the shared deleteUserData helper (backend/src/prisma/delete-user-data.ts:15-64); (4) delete the Profile row itself, which cascades (via Prisma's onDelete: Cascade) to AuthCredential, AuthIdentity rows, RefreshToken rows, AuthOneTimeToken rows, and BroadcastMessageRead rows. See the Data Model and "What this does NOT do" sections for exactly what does and doesn't get removed.

Client session handling (mobile)

AuthProvider (mobile/src/providers/AuthProvider.tsx) keeps the access token in memory only (never written to disk) and persists the refresh token plus a non-sensitive cached identity via expo-secure-store (mobile/src/lib/auth/tokenStore.ts:1-16), using AFTER_FIRST_UNLOCK keychain accessibility rather than the default WHEN_UNLOCKED — the app has background location enabled, and iOS can relaunch it for a location update while the phone is still locked, which throws under WHEN_UNLOCKED (tokenStore.ts:19-33). A silent refresh is scheduled ~1 minute before the current access token's exp (REFRESH_SKEW_MS = 60_000, AuthProvider.tsx:378), and is also triggered reactively by any 401 from the API client and retried on app foreground if a prior attempt failed transiently. All refresh triggers (scheduled timer, every 401, foreground retry) are coalesced into one in-flight network call (AuthProvider.tsx:282-346) so a burst of failures never becomes a burst of refresh requests. Only an authoritative 401 (the server explicitly rejecting the token) tears down the session; a network failure during refresh does not.

Data model (Prisma models and key fields)

All in backend/prisma/schema.prisma.

API surface (endpoints, auth requirements)

All under backend/src/auth/auth.controller.ts unless noted. @UseGuards(ThrottlerGuard) applies to the whole auth controller; individual routes add their own rate limits.

Method & path Guard Purpose
POST /api/auth/signup OptionalJwtAuthGuard (10/min) Password sign-up; dev/test only in practice
POST /api/auth/signin none (10/min) Password sign-in; dev/test only in practice
POST /api/auth/anonymous none (5/min) Create/reuse a device-scoped guest session
POST /api/auth/oauth/:provider OptionalJwtAuthGuard (5/min) Google/Apple sign-in; providergoogle|apple
POST /api/auth/refresh none Exchange refresh token for a fresh access token (non-rotating)
POST /api/auth/signout none Revoke one refresh token
POST /api/auth/signout-all JwtAuthGuard Revoke every session for the caller
POST /api/auth/verify-email, GET /api/auth/verify-email none Consume an email-verify token
POST /api/auth/resend-verification none (3/min) Generic response — no account enumeration
POST /api/auth/password/forgot none (3/min) Generic response — no account enumeration
POST /api/auth/password/reset none Consume a reset token, revokes all sessions
POST /api/auth/magic-link/request OptionalJwtAuthGuard (5/min) Email a 6-digit code
POST /api/auth/magic-link/verify OptionalJwtAuthGuard (5/min) Verify the code, issue a session
GET /.well-known/jwks.json none Public signing keys (jwks.controller.ts)
GET /api/account/public-profile JwtAuthGuard Returns { slug, url, enabled }, lazily assigns a slug
PATCH /api/account/public-profile JwtAuthGuard Body { enabled: boolean } — toggles publicEnabled
DELETE /api/account JwtAuthGuard Full account deletion
GET /u/:slug none Public profile HTML page
GET /u/:slug/card.png none Public profile as a PNG OG card
GET /api/avatar/:seed.svg none Deterministic generated avatar
POST /api/sync/reconcile-device JwtAuthGuard Merge stale same-device guest profiles (anonymous caller only)
POST /api/sync/reset-progress JwtAuthGuard Deletes patches/trips/achievements/progression (not purchases)
POST /purchases/migrate JwtAuthGuard Push locally-queued guest purchases to the now-signed-in account

JwtAuthGuard accepts a guest's token exactly like a member's — a guest is not blocked at the HTTP layer from any of these routes it's authorized for by role. What actually differs guest vs. member is enforced client-side (see next section) or by data-shape checks like public-profile.service.ts's if (!profile || profile.isAnonymous || !profile.publicEnabled) return null; (:26) — a guest can call PATCH /api/account/public-profile and set enabled: true, but their /u/<slug> page will still 404 unconditionally.

Key files (annotated path:line list)

Backend:

Mobile:

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Backend (describe/it counts from a direct grep, not run in this session):

Mobile (screen-tests/, each also present in the screen-test drift registry):

E2E: mobile/maestro/tests/auth.yaml (tags: auth, onboarding, smoke, critical) — provisions a fresh account via the backend directly, cold-boots to a signed-out state, signs in through the dev card, walks the full location→notifications permission chain, and returns the app to signed-out. Absorbed the former referral-loop.yaml on 2026-08-26 since both started from the same fresh-account state.

Open questions