// starters/auth/auth-patterns

auth patterns

The six recurring auth shapes (where the check lives, library choice + drift, session storage, webhook signatures, RBAC vs ABAC, OAuth state) and the decisions to make once so the AI stops re-deciding them every session.

last fed 28 may 20261055 words · 5 min read
// when to use this starter

Fork this at the start of any project that has user accounts. The decisions encoded here are the ones that get expensive to change later — they're load-bearing for every protected route you'll ever add.

authsecuritysessionoauthrbacwebhook

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:

  • Stripestripe.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-256 header.

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

Companion tools (Fizzgig)

  • fizzgig__auth_flow_trace — flags unprotected routes, webhook handlers without signature verification, auth library drift
  • fizzgig__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.