Authentication
How Supabase Auth, cookie sessions, application roles and row-level security protect the TanStack + Supabase starter.
This kit uses Supabase Auth for users, both apps and agent connections alike. The shared authentication page explains the concepts; this page shows where they live in this repository. Paths are given for apps/web; the admin app mirrors the same src/.server/ modules.
How a request is authenticated
The root middleware in apps/web/src/root.tsx creates one RequestServices object per request (apps/web/src/.server/request-context.ts) and places it in servicesContext. Loaders and actions call context.get(servicesContext) and get:
supabase(): a request-scoped@supabase/ssrserver client that reads the session from cookies. Row-level security applies to every query.serviceSupabase(): a service-role client that bypasses RLS. Call it only after authenticating and authorizing the caller in the same request.user(): the current Supabase user, ornull.
Outgoing auth cookies accumulate on the response, so session refreshes reach the browser. Sessions are Supabase cookie sessions scoped to each app's own host; the local config.toml sets jwt_expiry = 3600.
Guards live in src/.server/auth.ts of each app:
requireUser(context)redirects to/login?next=<current path>when there is no session.requireAdmin(context)callsrequireUserand then returns403unlessuser.app_metadata.user_role === "admin".
In the web app, the /app/* routes nest under routes/app-layout.tsx, which requires a user, current terms acceptance and completed onboarding. In the admin app, the /admin/* routes nest under routes/admin-layout.tsx, which requires an admin.
Sign-up, login and recovery
Web app (apps/web/src/routes.ts):
| Flow | Route | What happens |
|---|---|---|
| Sign up | /sign-up | auth.signUp with emailRedirectTo set to ${APP_URL}/auth/callback?next=.... Shows "Check your inbox to confirm your email." |
| Password login | /login | auth.signInWithPassword, then redirects to next (default /app). |
| Magic link | /login, "Or email me a sign-in link" | auth.signInWithOtp with the same callback. |
| Forgot password | /forgot-password | auth.resetPasswordForEmail redirecting to /auth/callback?next=/reset-password. |
| Reset password | /reset-password | Sets the new password for the recovered session. |
| Code exchange | /auth/callback | auth.exchangeCodeForSession(code), then redirects to next. |
| Token hash | /auth/confirm | auth.verifyOtp({ token_hash, type }) for email or recovery links. |
| Logout | /logout | Ends the session. |
The admin app (apps/admin/src/routes.ts) has the same /login, /forgot-password, /reset-password, /auth/callback, /auth/confirm and /logout routes on its own origin, with next defaulting to /admin. It has no /sign-up: administrators are regular accounts created on the web app (or from /admin/users) and granted the admin role as described below.
Every next value passes through safeRedirectPath, so only same-site paths are accepted. If next starts with /mcp/, the app uses a full-document redirect so an agent consent request survives the login.
Email confirmation and the local inbox
Local Supabase Auth requires confirmation before sign-in (enable_confirmations = true in apps/database/supabase/config.toml). Confirmation, magic link and recovery emails are sent by Supabase Auth, not by the apps. Locally, [local_smtp] is enabled and mail lands in Mailpit; open the MAILPIT_URL printed by pnpm --filter database exec supabase status --output-format json. In production, configure SMTP in the Supabase project's Auth settings as described in emails with Resend.
The seeded accounts (testadmin@usenextbase.com, alice@example.com, bob@example.com, carol@example.com, password password123) are pre-confirmed so you can skip the inbox during development.
Redirect URLs
config.toml allows these redirect targets for local development:
site_url = "http://localhost:3000"
additional_redirect_urls = [
"http://localhost:3002/auth/callback",
"http://localhost:3002/auth/confirm",
"http://localhost:3000/auth/callback",
"http://localhost:3000/auth/confirm",
]/auth/callback and /auth/confirm are the only auth routes registered in apps/web/src/routes.ts and apps/admin/src/routes.ts, and they are the only entries in the allowlist. In production, add the same two paths under both the web and the admin origin in the Supabase dashboard (Auth, URL Configuration) and keep Site URL on the web app's APP_URL.
Social providers
config.toml enables Google, GitHub and Twitter for the local Auth service, reading their credentials from GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, TWITTER_API_KEY and TWITTER_API_SECRET in the shell that runs the CLI.
Neither app shows provider buttons. The login and sign-up pages offer password and magic link only, and there is no signInWithOAuth call in apps/web/src or apps/admin/src. To add social login, add the provider button and auth.signInWithOAuth call to routes/login.tsx and routes/sign-up.tsx, then enable the provider and its callback URL in your Supabase project.
Becoming an application admin
Application administration is separate from workspace roles. Being a workspace owner does not open /admin.
The admin role is a row in public.user_roles with role = 'admin', mirrored into the user's app_metadata.user_role claim. Assign it with the SQL function shipped in apps/database/supabase/migrations/20240901054606_application_admin.sql:
select public.make_user_app_admin('<user uuid>');The function refuses to run unless the current role is postgres, supabase_admin or dashboard_user, so run it from the Supabase SQL editor or a direct psql connection. It inserts the role row and writes user_role = "admin" into auth.users.raw_app_meta_data. The user must sign out and in again to receive the new claim. remove_app_admin_privilege_for_user(uuid) reverses it. The seed grants the role to testadmin@usenextbase.com the same way.
Access-token hook
The migrations also define public.custom_access_token_hook, which would copy user_roles into the JWT on every token refresh, but the [auth.hook.custom_access_token] section in config.toml is commented out. Admin access works through make_user_app_admin without it. If you enable the hook in your Supabase project, keep public.user_roles as the source of truth.
Where the admin area lives
Administration is a separate deployment, apps/admin, running on http://localhost:3002 locally and on its own origin in production (APP_URL of the admin app). It serves /admin, /admin/users, /admin/workspaces, /admin/billing, /admin/marketing and /admin/feedback, and / redirects to /admin. Admins also get the Admin MCP connection page at /mcp/connections/admin on that origin. The web app no longer registers any /admin route; both apps share the same Supabase project and RLS rules, and every privileged request re-checks the admin claim with requireAdmin before touching serviceSupabase().
Support sign-in from /admin/users uses the service-role client to call auth.admin.generateLink({ type: "magiclink" }) for the chosen user and builds a link to the web app's /auth/confirm (WEB_APP_URL) with the hashed token and next=/app. The web app verifies the token and sets its own host-scoped session cookies; the admin session stays on the admin origin. The link can be copied or emailed through Resend when that integration is configured. See docs/admin-app.md in the repository.
What is protected, and how
- Row-level security is the real tenant boundary. Every table has RLS enabled with explicit grants for
anon,authenticatedandservice_role. Helper predicates includepublic.is_workspace_member,public.can_write_workspace_resources,public.is_workspace_adminandpublic.is_application_admin. - Workspace roles are
owner,admin,memberandreadonlyinworkspace_members.workspace_member_role, plus apermissionsJSONB with flags such asview_projectsandmanage_billing. Loaders still check membership (for examplerequireWorkspaceAccessinroutes/workspace.tsx) to return clean403and404responses. - Atomic writes go through
security definerSQL functions withset search_path = '', such ascreate_workspace_with_owner, instead of chains of Data API calls. - Service role usage is confined to
src/.server/and*.server.tsmodules, afterrequireUserorrequireAdmin. - Form actions are only accepted from the app's own origin (
isCanonicalOriginRequestinsrc/.server/url.ts, derived fromAPP_URL). Each build's smoke test posts to a sign-in route from a foreign origin and expects400. - Agent connections re-check the live Supabase session, confirmed email, ban state and current role or membership on every tool call through
public.mcp_live_actor. See agent connections.
The pgTAP suite in apps/database/supabase/tests/ covers the allowed path, cross-tenant denial and restricted-role denial for these boundaries. Run it with pnpm test-db.