---
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

**Degree of freedom: MIXED — declared per phase**

- Phases 0–4 (discovery, judgment): **[HIGH freedom]** — reason about what
  you find; checklists prompt investigation, they are not tick-scripts.
- Phase 5 (live probes) and the getSession grep: **[LOW freedom — run
  exactly]** — a skipped probe is an unproven claim, not a saved step.

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.

## How to reason in this audit

For every potential finding, reason in this order before recording it:

1. **Observe** — what does the code/probe actually show? (quote the line or the response)
2. **Interpret** — what does that mean for who can reach what? (don't assume intent)
3. **Classify** — real exposure / defense-in-depth-gap / correct-as-is / needs-a-probe
4. **Severity** — by blast radius: unauth data access & auth bypass = critical;
   everything else justified against what an attacker actually gains.

If you cannot complete Interpret from static code, it is a **needs-a-probe**
item for Phase 5, not a conclusion.

## Worked example

> **Observe:** `middleware.ts` matcher is `['/dashboard/:path*']`. The route
> `/api/orders/[id]` has no check in the handler.
> **Interpret:** API routes are not under the matcher, so middleware never
> runs on them; the order endpoint is reachable without auth. Layout-level
> checks don't cover direct API calls.
> **Classify:** real exposure (not merely a defense-in-depth gap — data is
> actually reachable).
> **Severity:** critical — unauthenticated data access.
> **Finding row:** `/api/orders/[id]` | edge+route | critical | matcher
> excludes /api; handler has no `getUser()` check | probe P5-a confirmed
> 200 while logged out.

## 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  [HIGH freedom]

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  [HIGH freedom — the centerpiece]

Every protected route / action, one row. Apply the reasoning chain per 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  [HIGH freedom, except the grep = LOW]

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

- **[LOW freedom — run exactly]** `grep -rn "getSession(" <server-code-paths>`.
  Treat every server-side hit as a finding until proven client-side-only.
  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. 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  [HIGH freedom]

**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  [HIGH freedom]

**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  [LOW freedom — run each, in order, non-prod]

Force the failures rather than only reading code. Log each as
`P5-x | expected | actual | evidence`:

- **P5-a** Hit each protected route / API / action **unauthenticated** →
  expect deny (not a redirect that still returns data).
- **P5-b** Replay a **logged-out** session token → expect reject.
- **P5-c** Tamper the session cookie / swap another user's identifier →
  reject.
- **P5-d** IDOR: as user A, request user B's object by id → deny.
- **P5-e** 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.
- **P5-f** User-enumeration: compare wrong-password vs nonexistent-user
  (text + timing).

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

---

## Self-critique before reporting  [LOW freedom — do not skip]

Challenge every finding against this rubric; drop or downgrade any that fail:

1. **Evidenced, not assumed** — quoted line or probe result? If it rests on
   "the code probably…", mark unconfirmed or run the probe.
2. **Reproducible** — did the probe succeed twice, or once? Flaky ≠ finding.
3. **Severity justified** — "critical" requires actual unauth access or
   bypass, not theoretical risk.
4. **Right layer** — app-layer (yours) or data-layer (→ `plan-rls-audit`)?
5. **No false safety** — did you confirm a gate *works*, or only that it
   *exists*? A check a probe bypassed is critical, not a pass.

---

## Definition of Done

- [ ] Auth stack + framework version recorded
- [ ] Degree-of-freedom respected: Phase 5 probes and the getSession grep
      run exactly, in order
- [ ] Route×gate matrix complete; every row reasoned
      Observe→Interpret→Classify→Severity
- [ ] 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
- [ ] Self-critique rubric applied; unevidenced/flaky findings dropped or
      downgraded
- [ ] 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 — each
   finding in the worked-example shape
3. **Provider-trap list** — exact call to change (`getSession` →
   `getUser`, etc.)
4. **Probe log** — P5-x | 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.
