Auth patterns
A sourdough starter for the auth-shaped decisions that need to be made once and then honoured everywhere. The AI's default behaviour is to re-invent auth per route, which is how protected routes ship unprotected.
1. Where the auth check lives
The single most important decision. Three viable options:
- Middleware (recommended for most cases). One file gates a whole path prefix (
/dashboard/*,/api/admin/*). Audit surface = one file. New routes inherit protection by default. Hardest part: getting the matcher right so static assets / public routes don't get blocked. - Per-route (in the handler). Each route imports an auth helper and calls it at the top. Audit surface = N files; one missed import = one unprotected route. Use this when the protection rules vary per route (different roles, different scoping).
- Per-component (in server components). For server-component-rendered protected pages. Often in addition to middleware — middleware blocks the request, the component still verifies the session before rendering.
The decision worth locking: "Auth checks live in middleware for all /dashboard/* and /api/admin/* routes. Per-route checks only when the protection logic varies. Server components additionally call getSession() before rendering protected data — defence in depth."
2. Library choice + drift prevention
Pick one. Mixing auth libraries in one project is a known antipattern — they fight over the session cookie, the AI cargo-cults patterns from one into the other, audit surfaces multiply.
Common choices (2026):
- Supabase Auth — if you're already on Supabase. Fewest moving parts.
- Clerk — best out-of-the-box UI; expensive at scale.
- NextAuth / Auth.js — most flexibility; most ceremony.
- Lucia — for the "I want to understand every byte" crowd. Pre-built primitives, no opinions.
- Iron Session / Cookie-only — for the "I just need a session" crowd. Minimal.
The decision worth locking: "We use {one library}. No other auth library is allowed in this codebase. If a new dependency proposes its own auth (e.g. Clerk for the dashboard, NextAuth for the API), reject it — refactor the new dependency's auth into the existing library or don't add the dependency."
auth_flow_trace catches library drift (mixing next-auth + clerk + supabase + auth0 + iron-session + lucia in one source).
3. Session storage
Where does the session live?
- HTTP-only cookie (recommended). Server reads it; JavaScript can't. Resistant to XSS exfiltration. The library handles this for you in most cases.
- localStorage / sessionStorage. Tempting because it's easier to read client-side. Don't do it — XSS can exfiltrate everything in localStorage.
- In-memory only. Refresh = logged out. Useful for some sensitive sub-flows (e.g. fresh auth before a password change), not for the primary session.
The decision worth locking: "Sessions live in HTTP-only secure cookies. The cookie is SameSite=Lax by default; SameSite=Strict for the admin scope. Refresh tokens (if used) are also HTTP-only."
4. Webhook signature verification
Every incoming webhook is a request from the public internet. Verify the signature first, before touching the body.
The pattern, by provider:
- Stripe —
stripe.webhooks.constructEvent(rawBody, signatureHeader, webhookSecret). Raw body bytes, not parsed JSON. Most frameworks parse the body before your handler runs; you need a path to get the unparsed bytes. - Supabase Auth webhooks — HMAC SHA-256 of the body with the webhook secret.
- Svix / Hookdeck / Inngest — provider SDK verification.
- GitHub webhooks — HMAC SHA-256 of the body with the webhook secret, in
X-Hub-Signature-256header.
The decision worth locking: "Every webhook handler verifies signature first, returns 401 silently on failure (don't echo why), and is idempotent (event ID stored to dedupe replays). Webhooks are added behind app/api/webhooks/<provider>/route.ts — one folder so the audit surface is one place."
5. Authorisation (the layer above authentication)
Once you know who the user is, what can they do? Two patterns:
- RBAC (Role-Based Access Control) — users have roles (
admin,editor,viewer), roles have permissions. Simple, scales to most apps. - ABAC (Attribute-Based Access Control) — permissions derived from attributes ("can edit if
resource.owner_id == user.id"). More expressive, more complex.
For most projects: RBAC at the route level, ABAC at the resource level. "Admin can access the admin routes (RBAC); within the admin routes, they can only see records belonging to their org (ABAC, enforced by RLS)."
The decision worth locking: "Roles: {list yours}. Role lookups happen in middleware (for route gating) and in RLS policies (for row-level scoping). Roles never live in JWT claims that the client could decode and lie about — always re-read from the database."
6. OAuth state + callback
When you wire third-party OAuth (Google, GitHub, Stripe Connect, etc.):
- State parameter is mandatory. Without it, you're vulnerable to CSRF on the callback. Most libraries do this; verify yours does.
- Callback URL is exact-match. Whitelist the production callback explicitly. Don't whitelist
*.yourdomain.com— subdomain takeover becomes auth bypass. - Validate the token at the OAuth provider, not just locally. The token in the callback URL has been intercepted before; check it's still valid at the provider before issuing your session.
- Store the OAuth provider's user ID, not the email. Emails change. Provider IDs don't.
The decision worth locking: "OAuth providers we support: {list}. State parameter validated on every callback. Provider user ID is the immutable join key, email is profile data only."
How to feed this starter
Add to it when:
- You add a new auth provider or library. Document the integration choices.
- You hit an auth-shaped bug worth recording (e.g. "the SSR createClient vs CSR createClient mixing bug" — common Next.js + Supabase trap).
- You change one of the locked decisions above. Update + note the supersession + why.
Remove from it when:
- A pattern stops applying (you retired RBAC in favour of pure ABAC, say).
- A library you don't use stops being relevant.
Companion starters
- security-posture — the broader security defaults
- project-starter — parent shape
- adr — auth decisions are exactly the shape ADRs are for
Companion tools (Fizzgig)
fizzgig__auth_flow_trace— flags unprotected routes, webhook handlers without signature verification, auth library driftfizzgig__form_validation_audit— server validation parity (the boundary between auth-checked-user and trusted-input)fizzgig__rls_checker— row-level scoping for the ABAC layer
All fold into fizzgig__audit at June 2026 launch.