---
name: route-auth-sweep
description: Enumerate every API route and prove each one gates before it touches data — authentication, ownership checks, role checks, rate limiting — so no endpoint is accidentally public or leaks another user's records. Use before a launch, after adding routes, during a security review, or when the user asks whether their API is safe.
---
<!--
  route-auth-sweep — from Toolbay
  https://toolbay.ai/product/route-auth-sweep
  Free to use, modify, and share. Keep this line and others can find it too.
-->


# Route Auth Sweep

## Install

Save this file as `~/.claude/skills/route-auth-sweep/SKILL.md`, or
`.claude/skills/route-auth-sweep/SKILL.md` to scope it to one repo. Claude Code
auto-discovers it. Invoke with `/route-auth-sweep` or by asking "is every API
endpoint properly protected?".

## Why this exists

Auth bugs do not come from hard problems. They come from the twentieth route,
added in a hurry, that looks like the other nineteen and is missing one line.
Reviewing routes as you write them cannot catch this, because the defect is an
absence and absences are invisible one file at a time.

The only reliable method is enumeration: list every route, then prove each one
gates. A route you did not enumerate is a route you did not check.

The highest-severity finding is almost never "no auth". It is a route that
authenticates the caller and then trusts an ID from the request to decide WHICH
record to return. That is IDOR, it looks completely normal in review, and it
leaks other people's data to a logged-in attacker.

## Step 1 — Enumerate every route

```
rg --files -g '**/route.ts' -g '**/route.js' -g '**/api/**/*.ts' -g '**/*controller*' | sort
```

Count them and keep the list. It is your checklist, and the report must account
for every entry.

## Step 2 — Classify intent BEFORE reading the code

For each route decide what it SHOULD require, from its name and purpose alone:

- **PUBLIC** — genuinely open (health, public catalog reads, webhooks, OG images).
- **AUTHED** — any signed-in user.
- **OWNER** — a signed-in user acting on their OWN records.
- **ROLE** — a specific role (admin, seller).
- **SIGNED** — no session, but a verified signature or token (payment webhooks,
  cron, magic links).

Doing this first stops you rationalising whatever the code happens to do.

## Step 3 — Read each route and find the gate

For each, answer four questions with a file and line, not an impression:

1. **Does it gate before touching data?** The check must run before any query.
   A route that fetches a record and then checks permission has already done the
   work, and error messages or timing frequently reveal existence.
2. **Where does the identity come from?** It must come from the session or a
   verified token. **Any user id read from the request body, query string, or a
   path parameter is a finding**, even if a session also exists.
3. **Is the query scoped to the caller?** For OWNER routes the database query
   itself must constrain ownership:
   - Safe: `findFirst({ where: { id, userId: session.user.id } })`
   - Unsafe: `findUnique({ where: { id } })` then compare in application code,
     and catastrophic if the comparison is missing or only logs.
4. **Is it rate limited?** Especially auth, password reset, signup, checkout,
   and anything that sends email. Note whether the limiter is shared across
   instances or per-process, because per-process limits barely exist under
   serverless autoscaling.

Useful greps, but never a substitute for reading:

```
rg -n "requireUser|requireAuth|getSession|getServerSession|requireAdmin|assertRole" -g '**/route.ts'
rg -n "rateLimit|ratelimit|throttle" -g '**/route.ts'
rg -n "body\.(userId|user_id|accountId|ownerId)|params\.(userId|accountId)" -g '**/route.ts'
```

The third one is the money grep. Every hit deserves a careful read.

## Step 4 — Check the mutating verbs hardest

For POST, PUT, PATCH, DELETE also confirm:

- **CSRF protection** for cookie-authenticated state changes (origin check or
  token). Cookie auth without it means any site can act as your logged-in user.
- **Input validation** with a schema, not hand-rolled `if` checks.
- **Server-computed values.** Prices, totals, roles, and permissions must never
  be taken from the request. Recompute them server-side from trusted records.
- **Webhook signature verification** BEFORE parsing or acting on the body.

## Step 5 — Verify the important ones live

Static reading misses middleware and framework behaviour. For your highest-risk
routes, prove it:

```
curl -s -o /dev/null -w "%{http_code}\n" https://<host>/api/<protected-route>
curl -s -X POST -o /dev/null -w "%{http_code}\n" https://<host>/api/<protected-route>
```

Unauthenticated must return 401 or 403, never 200, and never a 500 that reveals
the route ran. If a protected route returns 200 to no credentials, stop the sweep
and report it immediately.

## Step 6 — Report

```
Routes enumerated: <n>

CRITICAL
  <file:line>  <method> <path>  — <no gate | trusts body id | unscoped query>
                                  reachable by: <who>
                                  leaks: <what data, concretely>

MEDIUM
  <file:line>  — <missing rate limit | missing CSRF | validates after query>

VERIFIED SAFE
  <n> routes gate correctly (list them; the checklist must total <n>)
```

For every CRITICAL, state the concrete attack in one sentence: who sends what, and
what comes back. "A signed-in user can read any other user's orders by changing
the id in the URL" gets fixed today. "Improper access control" does not.

## Rules

- Enumerate first. An unlisted route is an unchecked route.
- "It gates" is not a finding; cite file and line, or you have not checked it.
- Authenticated is not authorised. Most real breaches are logged-in users reading
  other people's rows.
- A public route is a valid answer. Say so explicitly and say why, rather than
  proposing auth on a health check.
- Never report a vulnerability you have not traced end to end. A false critical
  costs trust you need for the true one.
