audit-auth-flows · git:20260819.e47b315 · 2026-08-19 · sha256 45f8089bcb69e9ff

audit-auth-flows git:20260819.e47b315A

Immutable. This exact content never changes and is served at /api/v1/blob/45f8089bcb69e9ff.

---
name: audit-auth-flows
description: >
  Read-only audit of app-layer auth — route×gate matrix, session lifecycle,
  OAuth, and provider traps (getSession vs getUser, middleware-as-only-gate,
  CVE-2025-29927). Use when "audit our auth", "check middleware protection".
  RLS → plan-rls-audit. OWASP checklist → audit-security.
license: MIT
---

# audit-auth-flows — Middleware is edge routing, not a security boundary

Read-only. Map the auth surface and prove, route by route, that identity is
verified where it matters. Then stop. Hand fixes to a follow-up session.

**2026 consensus (official, post CVE-2025-29927):** Next.js middleware
runs before cache and routing. Vercel: *"We do not recommend Middleware
to be the sole method of protecting routes in your application."* An
audit that only reads `middleware.ts` and declares the app safe has
missed the point. Defense in depth: edge + route handler / server action
+ data layer. Each layer covers a different part of the request path.

## This skill vs neighbors

| Skill | Owns |
|---|---|
| **audit-auth-flows** (this) | Session lifecycle, route×gate matrix, OAuth, provider traps |
| `audit-security` | OWASP checklist (injection, headers, deps) — may fix inline |
| `plan-security-audit` | Plan-only hardening burndown |
| `plan-rls-audit` | Data-layer who-can-read-which-rows |
| `plan-secrets-audit` | Where `service_role` / keys live |
| `plan-privacy-compliance` | Consent / policy vs collection — not session gates |
| `backend-patterns` | Building the middleware / guard fixes after this audit |
| `test-exploratory` | Live guest vs logged-in probe (runtime complement to this static matrix) |

---

## Phase 0 — Detect the auth stack

Identify: provider (Supabase Auth / NextAuth / Clerk / Auth0 / custom),
framework protection (Next.js middleware + `matcher`, route guards,
server-action checks), session transport (cookie vs `localStorage` vs
`Authorization` header), token model (JWT vs server session). Enumerate
pages, API routes, server actions, edge functions, admin areas. Record
the pinned framework version — auth CVEs are version-gated.

---

## Phase 1 — Route × gate coverage matrix (the centerpiece)

Every protected route / action, one row:

| route/action | intended access | gate that protects it | verified server-side? | data-layer backstop? |

Hunt the gaps this exposes:

- **Matcher holes** — `config.matcher` misses a protected path. API
  routes and server actions often bypass page middleware — each needs
  its own check.
- **Layout-only protection** — a layout/wrapper is a client-side
  illusion. It does not gate data fetches or direct API/action calls.
- **Middleware-as-only-gate** — the CVE-2025-29927 class. Any route
  where middleware is the *sole* authorization layer is a finding
  **regardless of framework version or host**. Patching the CVE is not
  defense in depth.
- **Server action exposure** — Next.js server actions are POST
  endpoints callable directly. Each mutating action must check identity
  itself.
- **Unlisted routes** — grep the router; do not trust the matcher list.

---

## Phase 2 — Provider and framework traps

**Supabase (check first on this stack):**

- `getSession()` used for a **server-side authorization** decision →
  **critical**. Official JS docs: cookie/storage values *may not be
  authentic*; `getSession()` is strongly advised against for
  authorization. `getUser()` performs a network request to the Auth
  server and *can* be used to base authorization rules. Grep
  `getSession(` in server code; treat each hit as a finding until
  proven client-side-only. Newer SSR guides also use `getClaims()` to
  *refresh* cookies — that is not a substitute for verifying identity
  at the handler.
- Service-role key on any client-exposed path → `plan-secrets-audit`.
  RLS assumed but not enforcing → `plan-rls-audit` (data-layer
  backstop; this skill is the app layer above it).
- `autoRefreshToken` disabled without manual refresh; JWT expiry
  absurdly short (<300s) hammering refresh.

**Next.js CVE-2025-29927** (GHSA-f82v-jwr5-mffw, critical): bypass
authorization if the check occurs only in middleware, via the internal
`x-middleware-subrequest` header. **Patched:** 12.3.5 / 13.5.9 /
14.2.25 / 15.2.3. **Impacted:** self-hosted `next start` and
`output: 'standalone'`. Vercel-hosted apps were incidentally
protected; Netlify and Cloudflare Workers were not affected. Workaround
if unpatched: drop `x-middleware-subrequest` before it hits Next.
If below the fix → P0 *and* a defense-in-depth finding. Also sweep
later Next.js auth CVEs (do not treat 29927 as the only one).

**General rule:** any "the framework enforces auth at the edge"
assumption is a finding, whatever the framework.

**NextAuth / Clerk / Auth0** — session strategy (JWT vs database),
callback / redirect validation, and whether `auth()` / session checks
actually run server-side per protected operation.

---

## Phase 3 — Session lifecycle

**Cookies** — `HttpOnly`, `Secure`, `SameSite` (Lax min, Strict for
sensitive); scoped path/domain; not readable by JS. Token in
`localStorage` is XSS-exfiltratable — flag it; CSRF-vs-XSS must be a
deliberate choice.

**Logout that actually logs out** — a stateless JWT stays valid until
expiry if logout only clears the client. Confirm server-side
invalidation (Supabase `signOut` revokes the refresh token). Probe it
(Phase 5). Official sessions FAQ: access tokens can still be presented
after sign-out unless the app also checks revocation / uses
`getUser()`.

**Rotation & timeouts** — rotate on privilege change (login, role
elevation) to kill fixation; idle timeout and absolute-max lifetime
both present. 2026 norm: short access tokens, longer **rotated**
refresh tokens with reuse detection, capped absolute session.

**Refresh-token rotation + reuse detection** — official Supabase
sessions docs: a refresh token is single-use, with two exceptions
(default **10s reuse interval** — do not widen; parent-token retry for
lost responses). Outside those exceptions the **whole session family
is revoked**. Disabling that in Advanced Settings is "generally not
recommended" — flag if widened or disabled.

---

## Phase 4 — Credentials, OAuth, authorization

**OAuth / OIDC** — `state` (CSRF on the callback), PKCE for public
clients, redirect-URI allowlist exact-match, provider `email_verified`
checked before trust, account-linking collisions handled.

**Credential flows** — reset tokens single-use + expiring + not leaked
in URL/referrer; **user enumeration** via differing error text or
timing on login/reset/signup; rate limiting on auth endpoints
specifically; MFA state cannot be skipped mid-flow.

**Authorization above the data layer** — not just "is logged in" but
"is allowed"; IDOR on API params (`/api/orders/:id` scoped to caller?);
admin routes gated by role server-side. Name which layer each control
lives in. `plan-rls-audit` owns the data-layer half.

---

## Phase 5 — Live probes (non-prod only)

Force the failures rather than only reading code:

- Hit each protected route / API / action **unauthenticated** → expect
  deny (not a redirect that still returns data).
- Replay a **logged-out** session token → expect reject.
- Tamper the session cookie / swap another user's identifier → reject.
- IDOR: as user A, request user B's object by id → deny.
- If Next.js is version-vulnerable or self-hosted: send
  `x-middleware-subrequest` and confirm WAF/edge *and* a route-handler
  check still stop it.
- User-enumeration: compare wrong-password vs nonexistent-user
  (text + timing).

Do not run these against production. Do not write exploit PoCs into
the repo.

---

## Definition of Done

- [ ] Auth stack + framework version recorded
- [ ] Route×gate matrix complete for every protected route / API / action
- [ ] Matcher holes, layout-only, middleware-as-sole-gate, unprotected server actions surfaced
- [ ] `getSession()`-for-authz grepped; `getUser()` confirmed on every server gate
- [ ] CVE-2025-29927 (and later auth CVEs) checked against the pinned version
- [ ] Cookie flags, localStorage exposure, CSRF pairing assessed
- [ ] Server-side logout invalidation confirmed by probe; rotation + idle/absolute timeouts present
- [ ] Refresh reuse interval not widened; family revocation not disabled
- [ ] OAuth state / PKCE / redirect-allowlist / email_verified checked
- [ ] User-enumeration, reset tokens, auth-endpoint rate limits, MFA-skip checked
- [ ] App-layer authorization (roles, IDOR, admin) split from the RLS half
- [ ] Live probes run non-prod; each logged pass/fail
- [ ] Read-only — findings presented, not patched

## Output format

1. **Route × gate matrix** — the table, gaps highlighted
2. **Findings** — issue | layer (edge/route/data) | severity (critical = unauth access / auth bypass) | file:line or probe | fix shape
3. **Provider-trap list** — exact call to change (`getSession` → `getUser`, etc.)
4. **Probe log** — probe | expected | actual | evidence
5. **Fix plan** — auth-bypass and getSession-authz first, then session lifecycle, then enumeration/OAuth. Handoffs: `plan-rls-audit`, `plan-secrets-audit`, `plan-security-audit`, `backend-patterns`. Present and stop.