Authentication
Supabase Auth in App Router. Server Actions + Zod + rate limiting, RLS deny-all backing identity from session.
What's enforced
Authentication uses Supabase Auth via @supabase/ssr. Every auth mutation is a Server Action with Zod-validated input. Session state is stored in httpOnly cookies; the access token never reaches client JavaScript. Protected routes are gated by proxy.ts (Next.js 16 middleware), which redirects unauthenticated users to /login before the page renders.
Identity inside Server Actions and Server Components comes from getUser() (createServerClientWithCookies then auth.getUser()), which validates the session token server-side. The client-side Supabase client is used only to initiate sign-in / sign-up / OAuth, never to fetch authenticated data.
How the flow works
- User submits the login form. The Server Action validates input with Zod.
- Supabase Auth verifies credentials (or initiates the OAuth redirect).
- Supabase sets an httpOnly session cookie. The browser cannot read it; the cookie travels with subsequent requests.
proxy.tsreads the session cookie on every request to a protected route. Unauthenticated requests redirect to/login?next=<original-path>.- Server Actions and Server Components call
getUser()to get the current user. Returnsnullif the session is missing or expired. - On signup, a database trigger creates a row in
profilesautomatically. Application code never has to "create the profile" manually.
For the full architectural walkthrough, see Supabase authentication in Next.js App Router (2026).
Auth pages
| Route | Purpose |
|---|---|
/login | Email/password + OAuth providers (per config.ts) |
/signup | New account with full-name capture |
/reset-password | Send password-reset email |
/auth/callback | OAuth and email-verification callback, exchanges the auth code for a session |
The /auth/callback route is required for any flow that returns a code via URL (OAuth, magic links, password reset). It calls exchangeCodeForSession server-side, which is the secure pattern; never run the exchange in client code.
Server Actions
All auth mutations live in actions/auth.ts:
| Action | Purpose |
|---|---|
login(formData) | Email/password sign-in |
signup(formData) | New account registration |
loginWithGoogle() | Initiates Google OAuth redirect |
resetPassword(formData) | Sends reset email |
logout() | Clears session, redirects to / |
Each action validates input with Zod, applies rate limiting via the in-memory limiter, and returns a generic error message to the client on failure (the underlying error stays in server logs).
Reading the current user
import { getUser, getUserWithProfile } from '@/lib/supabase/server'
// Just the auth user
const user = await getUser()
// Auth user + profile row
const result = await getUserWithProfile()
if (result) {
const { user, profile } = result
}
Both helpers run server-side and use the cookie-bound Supabase client. getUserWithProfile joins the profiles row via createAdminClient() (service-role) to get app-specific fields.
For multi-factor and PKCE specifics (which @supabase/ssr uses by default), see Supabase OAuth, magic links, MFA.
Adding an OAuth provider
The summary version:
- Enable the provider in the Supabase dashboard (Authentication > Providers) with your OAuth client ID and secret.
- Add the provider to
config.tsunderauth.providers. - Add the login button in
components/forms/login-form.tsxthat calls aloginWith<Provider>()Server Action.
Full walkthrough: Recipes: Add an OAuth provider.
Why this is shaped this way
Identity is read from the session, not from the request body. A Server Action that takes { userId, ... } as input is the IDOR anti-pattern; the canonical pattern is to read identity from getUser() inside the action and use the request body only for the operation's parameters. This is the same posture documented in the security architecture most SaaS templates skip.