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
- User signs in via Supabase Auth
- Supabase validates credentials against database
- Client receives session token
- 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.
Sign Up
Basic Email/Password Sign Up
Client Component
Sign In
Email/Password Sign In
OAuth Sign In
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 querystate 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:- Search the email on the Auth Recovery page and inspect diagnostics.
- Create a recovery override with a support reason. Overrides default to 7 days.
- Keep both normal login and recovery email enabled unless the case needs only one path.
- Use Send recovery email. The platform sends the email; admins should not copy tokens or links manually.
- 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. - Revoke the override once the user has recovered access or the case no longer needs support access.
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_overridesprivate.auth_recovery_tokensprivate.auth_recovery_events
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:
- The public login form exposes an explicit “Continue with passkey” action so users can open the browser passkey picker without relying only on autofill.
- Account Security in the apps/web settings dialog owns passkey registration, rename, and delete actions. Do not add separate passkey settings pages.
- 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.
- Relying Party Display Name:
Tuturuuu - Relying Party ID:
tuturuuu.com - Relying Party Origins:
https://tuturuuu.com
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
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:POST /api/v1/auth/qr-login/challengesvalidates the request origin and the request-scoped Supabase session before inserting aqr_login_challengesrow. The row stores a hashed secret, request metadata,creatorUserId, and a two-minute expiry.- The client renders a
tuturuuu://auth/qr-loginpayload that contains the challenge id, one-time secret, and web origin. - The signed-in mobile app scans the code from Settings > Session. Mobile must have app lock enabled, then performs local authentication before approving.
POST /api/v1/auth/qr-login/challenges/:id/approvevalidates the mobile Bearer session, challenge secret, andcreatorUserId. The approver must be the same user that created the challenge.- 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 admingenerateLinkplus detachedverifyOtp.
Sign Out
Server-side Auth Resolution (getClaims first)
For server-side route and helper authorization checks, prefer a claims-first flow:
- Call
supabase.auth.getClaims()first. - Use
claims.subas the authenticated user id when available. - Fall back to
supabase.auth.getUser()when claims are unavailable or insufficient.
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 JWTsession_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.tssomapUrlToApp(...)can recognize itsreturnUrl. - Add
apps/<app>/src/app/api/auth/verify-app-token/route.tsso/verify-tokencan exchange the cross-app token for a host-onlytuturuuu_app_sessioncookie. When the satellite requires rewrittenapps/webAPI access, usecreatePOST('<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-sessionserver helpers and@tuturuuu/internal-apiprofile/default-workspace helpers instead;bun checkruns the static guard across all registered appsrcdirectories. - 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) andmanage_workspace_settings; they do not opt intoallowAppSessionAuthbecause the app-session path uses an admin-backed client that would bypass workspace delete RLS.
/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 withvalidateCrossAppToken(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
Inapps/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.
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
useUser Hook (auth-state subscription)
Subscribing to Supabase auth-state changes is a legitimate exception to the “nouseEffect 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
-
Always check authentication server-side
-
Redirect after authentication
-
Handle errors gracefully
-
Implement MFA for sensitive operations
❌ DON’T
-
Don’t trust client-side auth state alone
-
Don’t expose sensitive data in auth redirects
-
Don’t store passwords
-
Don’t use
createAdminClient()for auth operations
Related Documentation
- Authorization - Permission system
- Supabase Client - Client creation patterns
- RLS Policies - Database security