Skip to main content
The Tuturuuu platform uses Supabase Auth for user authentication with support for email/password, OAuth providers, multi-factor authentication (MFA), and cross-app token authentication.
The server helpers in @tuturuuu/supabase/next/server resolve cookies (and an optional request argument) asynchronously, so createClient() and createDynamicClient() are async and must be awaited. createAdminClient() is synchronous, but its return type allows awaiting, so existing call sites that await createAdminClient() still work — never await createClient() results without the keyword.

Authentication Flow

  1. User signs in via Supabase Auth
  2. Supabase validates credentials against database
  3. Client receives session token
  4. Subsequent requests include session token for RLS

Cross-App Supabase Cookies

Production Tuturuuu browser sessions use one canonical Supabase auth cookie for the configured Supabase project and share it across *.tuturuuu.com by setting Domain=.tuturuuu.com. Portless local development does the same across *.tuturuuu.localhost with Domain=.tuturuuu.localhost. Keep the cookie host-only for plain localhost, preview deployments, and unrelated domains. Registered satellite apps still keep Tuturuuu app-session JWTs as a fallback and API isolation mechanism, so do not remove app-session refresh or handoff paths when updating shared Supabase cookie behavior.

Web Multi-Account Sessions

apps/web stores multi-account sessions in a server-owned vault instead of browser localStorage. The browser keeps only an HttpOnly device cookie and loads account summaries from /api/v1/auth/accounts; Supabase access and refresh tokens remain encrypted in private.web_account_sessions. Use WEB_MULTI_ACCOUNT_SESSION_SECRET for vault encryption when available. If it is not configured, the server falls back to SUPABASE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY, then SUPABASE_SERVICE_KEY. Never expose those values to client components or upload legacy browser-stored sessions into the vault; users should re-add accounts after storage migrations.

Keeping stored sessions usable

Supabase rotates the refresh token every time the browser refreshes a session, so a stored copy is only good until the account it belongs to refreshes. A switch therefore writes the outgoing account’s live session into its row before calling setSession for the incoming one — without that, switching back fails, the row is deleted as unusable, and a retry reports the account as missing entirely. When a switch does fail, the response carries requiresReauth: true and the account has already been dropped from the vault. Clients must re-read /api/v1/auth/accounts on failure so the dead entry disappears, and should tell the user to sign into that account again rather than surfacing the raw error. A switch redirect is a navigation instruction, not a stored route: /login and /add-account are rejected for last_route on purpose, so never resolve an explicit targetRoute through the persistable-route filter or you will drop a sign-in that is still in flight.

Sign Up

Basic Email/Password Sign Up

Client Component

Sign In

Email/Password Sign In

OAuth Sign In

The providers offered on the login page and in account linking come from AUTH_OAUTH_PROVIDERS in apps/web/src/lib/auth/oauth-providers.ts — one list drives the buttons, the /api/v1/users/me/identities/link/[provider] route, and the settings linked-accounts card. Adding a provider anywhere means adding it there.
The Supabase Azure provider must be pinned to a single tenant. Microsoft (azure) is offered with the email scope, and Supabase links an OAuth identity into an existing account when the provider reports that address as verified. On the shared multi-tenant common issuer that claim is only an assertion by whichever directory the user signed in from, so anyone who controls any Azure directory could claim a Tuturuuu address and take over the matching account. Set the provider’s URL override to your tenant, not common.
Client Component:

OAuth Callback State

For provider-specific OAuth callbacks that need to carry ephemeral verifier or CSRF state across a browser redirect, prefer a short-lived HttpOnly cookie scoped to the callback route. When the callback binds external credentials or mutates a workspace, do not let the cookie value be the sole authority: use a signed, expiry-bound state payload that also binds the workspace or account being connected, then require the callback cookie and query state to match before verifying that payload.

Email-Based Auth Recovery

Infrastructure admins can use Infrastructure > Auth Recovery when a manually reviewed user cannot complete normal OTP or password sign-in because of email-scoped infrastructure blocks, stale Supabase bans, or OTP counters. The support flow is:
  1. Search the email on the Auth Recovery page and inspect diagnostics.
  2. Create a recovery override with a support reason. Overrides default to 7 days.
  3. Keep both normal login and recovery email enabled unless the case needs only one path.
  4. Use Send recovery email. The platform sends the email; admins should not copy tokens or links manually.
  5. The user can click the recovery link or enter the 6-digit code on /auth/recovery. Recovery credentials expire after 15 minutes and are single-use.
  6. Revoke the override once the user has recovered access or the case no longer needs support access.
Normal-login overrides bypass only email-scoped infrastructure and OTP/password rate-limit blocks. They still enforce malformed request validation, suspicious user-agent checks, Turnstile, password correctness, MFA, and active IP blocks unless an admin separately clears those IP blocks. When a valid override is used, the service also attempts to clear the Supabase auth ban and confirm the email. Recovery-email sign-in stores token and code hashes in private schema tables and audits sends, consumes, rejects, creates, revokes, and Supabase unban/create attempts. The recovery session is created with the existing admin generateLink plus detached verifyOtp pattern, then normal auth cookies are set. Redirects are limited to sanitized app paths. Database objects live in:
  • private.auth_recovery_overrides
  • private.auth_recovery_tokens
  • private.auth_recovery_events
Use AUTH_RECOVERY_HASH_SECRET in production when available. The code falls back to existing Supabase server secrets for local development, but do not expose those values to clients or support tooling. Do not run bun sb:push for this change; apply migrations through the normal database release process.

Passkeys

Passkeys use Supabase Auth’s experimental WebAuthn APIs. Browser clients created through @tuturuuu/supabase/next/auth-browser must pass auth.experimental.passkey: true; otherwise Supabase rejects auth.signInWithPasskey(), auth.registerPasskey(), and auth.passkey.* calls. apps/web owns passkey UX:
  1. The public login form exposes an explicit “Continue with passkey” action so users can open the browser passkey picker without relying only on autofill.
  2. Account Security in the apps/web settings dialog owns passkey registration, rename, and delete actions. Do not add separate passkey settings pages.
  3. Satellite apps should continue to route user authentication through apps/web cross-app auth. Passkeys are bound to the relying party domain, so the central apps/web origin remains the authority for Tuturuuu account passkeys.
Production Supabase Auth must have passkeys enabled with these relying party settings:
  • Relying Party Display Name: Tuturuuu
  • Relying Party ID: tuturuuu.com
  • Relying Party Origins: https://tuturuuu.com
Local Supabase Auth must also enable passkeys in apps/database/supabase/config.toml. The committed local config uses the Portless apps/web origin:
  • Relying Party Display Name: Tuturuuu
  • Relying Party ID: tuturuuu.localhost
  • Relying Party Origins: https://tuturuuu.localhost
Restart local Supabase after changing this config; the Auth service reads these settings on startup. Real WebAuthn ceremonies require a secure origin, so test registration and sign-in from https://tuturuuu.localhost. UI-only and unsupported-browser paths can still be exercised without registering a credential. Remote Supabase development auth is different: if a cloud Supabase project has captcha protection enabled, passkey sign-in must send a real Turnstile token. The local E2E bypass is honored only when NEXT_PUBLIC_SUPABASE_URL points at local Supabase. When testing remote Supabase from https://tuturuuu.localhost, the Turnstile site key must authorize that local hostname; Cloudflare Turnstile error 110200 means the widget cannot mint the token Supabase requires. The login UI keeps passkey sign-in blocked while that token is missing; either add the local hostname to the Cloudflare Turnstile widget used by the Supabase project, or point NEXT_PUBLIC_SUPABASE_URL at local Supabase for dev passkey testing.

Web QR Session Handoff

QR-based session handoff must never be a public login bootstrap. An unauthenticated browser cannot prove that it belongs to the account scanning the QR code, so public challenge creation would allow QR phishing where an attacker polls a victim-approved challenge and receives the victim’s session. The QR challenge endpoints are therefore constrained to authenticated, same-account handoff:
  1. POST /api/v1/auth/qr-login/challenges validates the request origin and the request-scoped Supabase session before inserting a qr_login_challenges row. The row stores a hashed secret, request metadata, creatorUserId, and a two-minute expiry.
  2. The client renders a tuturuuu://auth/qr-login payload that contains the challenge id, one-time secret, and web origin.
  3. The signed-in mobile app scans the code from Settings > Session. Mobile must have app lock enabled, then performs local authentication before approving.
  4. POST /api/v1/auth/qr-login/challenges/:id/approve validates the mobile Bearer session, challenge secret, and creatorUserId. The approver must be the same user that created the challenge.
  5. The creator polls GET /api/v1/auth/qr-login/challenges/:id?secret=.... Once approved, the server consumes the challenge and creates a fresh detached Supabase session via admin generateLink plus detached verifyOtp.
QR challenge rows never store access or refresh tokens. The table is RLS-enabled without anon/authenticated grants; API routes use the service role for challenge state and the request-scoped client to validate both the challenge creator and the mobile approver.

Sign Out

Server-side Auth Resolution (getClaims first)

For server-side route and helper authorization checks, prefer a claims-first flow:
  1. Call supabase.auth.getClaims() first.
  2. Use claims.sub as the authenticated user id when available.
  3. Fall back to supabase.auth.getUser() when claims are unavailable or insufficient.
This reduces auth latency on hot API paths while preserving correctness for call sites that still need canonical user resolution.
When implementing this pattern, feature-detect getClaims first. Some tests and older stubs only mock getUser, and unconditional getClaims calls will break those environments.

Multi-Factor Authentication (MFA)

Enable TOTP MFA

Verify MFA Enrollment

MFA Challenge During Sign In

Verify MFA Code

Mobile MFA Approval Cookies

Mobile approval for web MFA is scoped to the Supabase login session that created and consumed the approval challenge. When the web browser polls an approved mobile MFA challenge, store the current JWT session_id in the challenge approval metadata. The auth proxy must compare that stored session id with the current request claims before using ttr_mfa_mobile_approval to bypass the MFA redirect. Do not treat the approval cookie as a user-scoped remember-me token. A valid cookie only proves that one challenge secret was approved; it must also match the current Supabase session. Central logout responses should expire the approval cookie, and MFA redirects should clear stale approval cookies that do not satisfy the current-session binding.

Session Management

Get Current Session

Get Current User

Refresh Session

Password Reset

Request Password Reset

Reset Password

Email Verification

Resend Verification Email

Cross-App Authentication

The platform supports token-based authentication across different apps using @tuturuuu/auth/cross-app. When a new satellite app participates in centralized login, wire both sides in the same patch:
  • Register the app URL in packages/utils/src/internal-domains.ts so mapUrlToApp(...) can recognize its returnUrl.
  • Add apps/<app>/src/app/api/auth/verify-app-token/route.ts so /verify-token can exchange the cross-app token for a host-only tuturuuu_app_session cookie. When the satellite requires rewritten apps/web API access, use createPOST('<app>', { verificationBaseUrl: WEB_APP_URL }) so the handoff also stores the Web-issued app-session cookie.
  • Keep generate_cross_app_token(...) bound to the authenticated caller (p_user_id = auth.uid()) so verify endpoints cannot mint sessions for arbitrary users.
  • Do not call supabase.auth.setSession() in registered internal apps. The verifier route sets the HttpOnly app-session cookie, and satellite UI should fetch user/profile data by forwarding that cookie to central internal APIs.
  • Registered internal app source must not call supabase.auth.* directly. Use @tuturuuu/auth/app-session server helpers and @tuturuuu/internal-api profile/default-workspace helpers instead; bun check runs the static guard across all registered app src directories.
  • App-session auth is read/update oriented for satellite apps. Destructive workspace operations such as DELETE /api/workspaces/[wsId] require a full Supabase session (cookie or bearer) and manage_workspace_settings; they do not opt into allowAppSessionAuth because the app-session path uses an admin-backed client that would bypass workspace delete RLS.
If either piece is missing, centralized login can stall on the web login spinner or land on /verify-token with no token-verification endpoint to finish the handoff.

Generate Cross-App Token

generateCrossAppToken(supabase, targetApp, originApp, expirySeconds?) is the real signature. It reads the authenticated user from the passed Supabase client, calls the generate_cross_app_token RPC, and returns the token string (or null on failure). The origin app ('web') mints the token; the target app ('shortener', 'nova', 'rewise', etc.) verifies it.

Validate Cross-App Token

The target app validates the token with validateCrossAppToken(supabase, token, targetApp), which calls the validate_cross_app_token_with_session RPC and returns { userId } (or null). The target app is responsible for establishing its own session/app-session from that userId; the token never carries access or refresh tokens.
@tuturuuu/auth/cross-app does not export a verifyCrossAppToken function. For the browser-side handoff on /verify-token, use the exported verifyRouteToken({ searchParams, token, router }) helper, which POSTs the token to /api/auth/verify-app-token and lets the verifier route set the HttpOnly app-session cookie. revokeAllCrossAppTokens(supabase) invalidates a user’s outstanding tokens.

Proxy (Edge Middleware) Authentication

In apps/web the edge entry point that protects routes lives in apps/web/src/proxy.ts (not a middleware.ts file). It exports an async proxy(req) function plus a config.matcher, and Next.js is configured to use this file as the request middleware. The real implementation delegates the heavy lifting to createCentralizedAuthProxy from @tuturuuu/auth/proxy, then layers on onboarding checks, workspace-slug normalization, guest-route guards, and locale handling. See Routing for how the proxy coordinates those concerns. The simplified example below shows the core shape: resolve the user from a request-scoped Supabase client and redirect when unauthenticated. Note that createDynamicClient() is async and must be awaited.
The production proxy resolves the user with resolveAuthenticatedSessionUser(supabase) and propagates refreshed auth cookies onto every redirect via propagateAuthCookies(authRes, response). Reuse those helpers instead of re-implementing session resolution and cookie forwarding when you extend the proxy.

Protected Server Component

Client-Side Authentication

@tuturuuu/supabase/next/client is deprecated for CRUD/storage and may throw unless a compatibility flag is set. For browser data access, use @tuturuuu/internal-api. For the narrow case where the browser genuinely needs an auth client (reacting to live auth-state changes), use createAuthClient() from @tuturuuu/supabase/next/auth-browser. Do not fetch product data on the client through raw Supabase clients, and prefer TanStack Query over useEffect for any data fetching.

useUser Hook (auth-state subscription)

Subscribing to Supabase auth-state changes is a legitimate exception to the “no useEffect for data fetching” guidance: this hook does not fetch product data, it mirrors the live session into React state. Use createAuthClient and subscribe once.
For authorization decisions, always re-verify the user server-side (await supabase.auth.getUser() or the getClaims-first pattern above). Client auth state is for UX only — never trust it as the sole gate for protected data.

Usage

Identity Linking

Link multiple auth providers to same account:

Best Practices

✅ DO

  1. Always check authentication server-side
  2. Redirect after authentication
  3. Handle errors gracefully
  4. Implement MFA for sensitive operations

❌ DON’T

  1. Don’t trust client-side auth state alone
  2. Don’t expose sensitive data in auth redirects
  3. Don’t store passwords
  4. Don’t use createAdminClient() for auth operations

External Resources

Required MFA for internal accounts

Infrastructure administrators can require MFA or make it optional from Internal Accounts. Requiring MFA creates a new verification boundary: existing AAL2 tokens, app sessions, and mobile approvals from before that boundary cannot satisfy the policy. Users without a factor enroll on the web or mobile sign-in screen before continuing. Making MFA optional retains enrolled authenticators; Reset authenticators removes them and requires enrollment again when policy remains required. Administrators cannot change their own policy through this interface. The policy lives in server-owned auth.users.raw_app_meta_data under tuturuuu_required_mfa. User metadata and caller-supplied cross-app session data are never authorities. Cross-app handoff uses a separately signed, short-lived proof bound to the user and target. Refresh preserves the original verification time and mobile-approval expiry. Administrative authenticator recovery advances the boundary before deleting factors so partial recovery cannot retain old proof. Administrative password recovery also advances the boundary. AI cached identity tokens are disabled for required accounts; those requests use normal verified session authentication. Existing cached AI tokens are rechecked against fresh policy before use.

Rollout gate

Keep REQUIRED_MFA_POLICY_ENABLED unset until the migration and all application changes are deployed and verified. Infrastructure also requires the database required_mfa_enforcement_version() capability to return 1 before exposing policy changes. The environment gate is an operational release acknowledgement; the database version alone does not prove application readiness. Production migration application remains user-operated.
  1. Apply 20260923130000_required_account_mfa.sql locally and run apps/database/supabase/tests/required-account-mfa.sql.
  2. Verify web login, factorless enrollment, native enrollment, existing AAL1/AAL2 sessions, mobile approval expiry, authenticator recovery, and making MFA optional.
  3. Verify API guards in custom satellite proxies (including Mail, Infra and Calendar), privileged API reads/writes, browser/CLI/external token exchange, refresh, and invitation decisions. Mixed credentials must not borrow assurance from another session or account.
  4. Verify direct PostgREST RPC/table access, Storage and Realtime authorization. Existing open Realtime connections require explicit runtime verification during rollout; checking a reconnect alone is not evidence of immediate revocation.
  5. Deploy and verify the exact web/satellite source and mobile beta builds, apply the production migration through the authorized operator, then set REQUIRED_MFA_POLICY_ENABLED=true on Infrastructure.
A denied interactive API request returns 403 with MFA_REQUIRED. Mobile refreshes its provider session so its auth router can show verification/enrollment. Policy lookup failures return 503; they never grant access. Recovery routes stay reachable but retain their own authentication and challenge checks. Machine and webhook credentials retain their independent authentication contracts. The migration adds restrictive policies to existing application, Storage and Realtime authorization tables. New RLS tables added after this migration must also include the account_required_mfa restrictive policy; do not assume the PostgREST hook protects Storage or Realtime traffic.

Cached clients and reconnect

Optional accounts keep instant cached startup and offline access. Native startup and resume revalidate the session in the background; a known required policy with missing, stale, or expired proof routes immediately to the MFA screen. Web and satellite clients perform a background profile check on focus/reconnect; a MFA_REQUIRED response reloads through the server auth proxy into the challenge. Rotated provider cookies are retained on MFA responses so recovery does not become a password-login loop. An offline device cannot learn that an administrator has newly required MFA or advanced a recovery boundary. Previously accessible cached content remains bounded by the policy last known to that client until it reconnects. This feature does not remotely erase cached files or revoke previously issued public/signed download URLs. Do not describe it as immediate offline revocation. Administrator recovery blocks required accounts while factors or passwords are being changed. After recovery, both primary sign-in and MFA must happen after the new boundary. Existing web and native sessions show Sign in again before allowing enrollment. Signed app-session and mobile-approval proofs also retain the current verified factor identity; deleting that factor invalidates those proofs even if an in-flight verification finishes late. A newly enrolled factor cannot restore a proof tied to the removed factor. The rollout flag controls policy mutations only. It is not an enforcement bypass: existing required policy remains authoritative when the flag is off. Server requests fail with 503 AUTH_UNAVAILABLE if fresh account policy cannot be read, including requests from previously optional accounts. Optional accounts still retain instant cached startup and offline UI; this does not grant offline server access. Supabase private Broadcast/Presence authorization is cached for an open channel. The restrictive policy applies on a new join or fresh JWT; an existing connection can retain access until token refresh, reconnect, or JWT expiry. Postgres Changes uses table RLS separately, and public channels are not protected by private-channel policies. Do not claim immediate revocation of already-open broadcasts or signed URLs. See Supabase Realtime authorization and private channel settings. Administrative policy/recovery metadata transitions use a row-locked database compare-and-swap with a unique generation. A delayed completion cannot overwrite a newer recovery marker, even after the application coordination lease expires. Provider password changes remain GoTrue operations; optional-account password reset does not promise immediate revocation of every previously issued JWT, including when an administrator concurrently enables required MFA. Fresh required-policy and factor checks still apply to every protected request.