Skip to content
Chapter 82Lesson 7

Finding 6: the missing rate limit on password-reset

Document the unthrottled password-reset endpoint as finding 6, the sixth file in the audit report.

Five findings in, you have a rhythm: open the running app and the source side by side, walk one category, write the file. This finding breaks that rhythm in an instructive way, because the running app barely reacts to it. Submit the reset form once and you get a friendly 200. Submit it twenty times and you get twenty friendly 200s — no 429, no RateLimit-* header, no slow-down. The defect is invisible from the surface. You find it by reading the limiter declarations in rate-limit.ts against every api/auth/* handler and noticing that one limiter, resetLimiter, sits fully configured at module scope while the route that needs it sends mail on every POST without ever importing it. The cure is sitting one file over from the disease, and nothing connects them. The fingerprint, once you go looking, is the absence of a 429 — every line of the hammer loop’s output is a 200:

the hammer — twenty identical POSTs, no ceiling
200
200
200
200
200
... (twenty in total, never a 429)

The deliverable here is one Markdown file, findings/006-rate-limit-password-reset.md, with the four template sections filled in. You are not wiring the limiter and you are not touching the route — the audit is a read-only pass, the fix is a paragraph plus a short illustrative snippet, and the target runs unchanged when you are done.

Audit the password-reset endpoint for rate-limit coverage and write finding 6 documenting the gap. The thing to internalize on this one is how you find it: not by hammering the app until it breaks, but by reading the route list against the coverage matrix from chapter 81’s “The abusable-endpoint matrix”. You walk the declared limiters in src/lib/rate-limit.ts, then open each api/auth/* handler and check whether it actually runs through the limiter seam. The running app only confirms what the matrix already told you — twenty submits, no 429. The coverage rule mandates a limiter when an endpoint hits any one of three triggers: it costs money per call, it can be used to attack a third party, or it touches state addressable without auth. This endpoint hits two of them — the Resend send is money, and the “email” the caller supplies is a victim’s inbox — and your finding has to record which two fire, not just that the rule applies. Coverage is the deliverable, so the finding carries a matrix with a row for every abusable endpoint; the gaps you are not scoring here (sign-in and sign-up are declared-but-unwired too) go in as open rows, never silently dropped. The trap that catches inexperienced engineers is reaching for a single per-IP limiter and calling it done — the inbox-bomb and the enumeration attack are both per-email problems, so per-IP alone misses a distributed sender rotating addresses against one victim and, worse, locks out a whole shared office NAT. Document both the grep and the hammer, because they prove different things: the grep proves the gap lives in source and can be wired into CI later, the hammer proves the gap is live right now. Patching the route is out of scope.

findings/006-rate-limit-password-reset.md has all four template sections — Rule, Location, Consequence, Fix — populated with real prose.
tested
The finding names the rule as rate-limit coverage with its three mandatory triggers, links chapter 81’s rate-limit lesson by section, and records which two triggers this endpoint hits — the “which two” detail confirmed by hand.
tested
The Location names the discovery commands and both files read (src/lib/rate-limit.ts and src/app/api/auth/reset-password/route.ts), establishing that resetLimiter is declared but never imported by the route.
tested
The finding is confirmed against the running app: repeated password-reset submissions return no 429.
untested
The Consequence reads as inbox-bomb, account enumeration, and uncapped Resend cost, in user-visible and third-party terms, with no “could potentially” hedging.
untested
The Fix names wiring the per-IP resetLimiter plus a per-email companion limiter, both wrapped in safeLimit, returning a generic 429 with RateLimit-* headers — the dual-key detail confirmed by hand.
tested
A coverage matrix — endpoint category, file, limiter, key strategy, covered Y/N — is attached to the finding.
tested
The audit target still runs unchanged: the defect is documented, not patched.
tested

Write finding 6 against the template and the brief above, run the lesson-7 gate until it passes, then open the walkthrough to compare against the reference.

Reference solution and walkthrough

The finding lands at findings/006-rate-limit-password-reset.md. The header carries the category and a two-line severity justification before the four sections begin:

findings/006-rate-limit-password-reset.md — header
# Finding 006 — Unthrottled password-reset endpoint sends mail on every call
**Category:** Rate-limit coverage (security baseline).
**Severity:** high — the endpoint costs money on every request and weaponizes the product's verified domain against arbitrary inboxes, but it does not directly expose data or grant access, so it sits below the critical secret leak (finding 5) and above a config-only gap.

State the rule, then the threshold, then which triggers fire here. The rule is coverage: every abusable endpoint routes through one of the named limiters, and an endpoint is abusable — a limiter is mandatory — when it matches any one of three triggers. This endpoint matches two, so the limiter is not a nice-to-have. Link the source lesson by section rather than re-deriving the triggers.

findings/006 — ## Rule
## Rule
Every abusable endpoint routes through one of the named limiters in `lib/rate-limit.ts`, and an endpoint is abusable — a limiter is mandatory — when it matches any one of three triggers: it costs money per call, it can be used to attack a third party, or it touches state addressable without auth (chapter 081, lesson 2 — the threshold is three triggers, any one; coverage is the deliverable, tracked as a matrix). The password-reset request endpoint matches **two** of the three, so a limiter is not a nice-to-have here, it is required:
1. **Costs money per call.** Each request fires a Resend transactional send (lesson 2, trigger (a) — transactional email is the canonical money-per-call example).
2. **Attacks a third party.** The "email" the caller supplies is someone else's inbox; an attacker drives the send at a victim's address (lesson 2, trigger (b) — the victim's inbox is the third party).
(It is arguably trigger (c) as well — the route is reachable without a session — but the plan scores this finding against the two load-bearing triggers above; either one alone already makes the limiter mandatory.)

Note the parenthetical about trigger (c). The route is reachable without a session, so a case could be made for all three — but the finding scores against the two that are load-bearing rather than padding the count. Naming triggers honestly is part of the discipline; either of these two alone already makes the limiter mandatory, so the third is a footnote, not a third strike.

This is the section that makes the finding distinctive. The location is not a line in one file — it is the gap between two files. The limiter is declared, fully configured, exactly where chapter 81 said limiters live; the route that should reach it never does. So the Location names both files and the discovery commands that surfaced the gap:

findings/006 — ## Location
## Location
The defect is the *gap between* a declared limiter and the route that should use it, so the location is two files read side by side:
- `src/lib/rate-limit.ts`, lines 23–29: `resetLimiter` is declared at module scope — `Ratelimit.slidingWindow(3, '15 m')`, prefix `rl:reset`. The limiter exists, fully configured, exactly where the lesson says limiters live.
- `src/app/api/auth/reset-password/route.ts`, the `POST` handler at lines 20–44: it parses the email (lines 21–25) and calls `sendEmail(...)` at lines 31–39 with **no limiter in front of it**. Nothing in this file imports `resetLimiter` or `safeLimit`; the declared budget in `rate-limit.ts` is never reached from the one route that needs it.

To see exactly what the audit is describing, here is the declared limiter — sitting at module scope, ready to use, imported by nothing under src/app:

src/lib/rate-limit.ts — resetLimiter is declared and configured
export const resetLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '15 m'),
prefix: 'rl:reset',
analytics: true,
ephemeralCache: new Map(),
});

And here is the route that needs it, firing the send on every POST with nothing in front. The seeded comments already name the defect; the executable code is what matters — no limiter import, no gate, straight to sendEmail:

src/app/api/auth/reset-password/route.ts — sends on every call, no gate
export const POST = async (request: Request): Promise<Response> => {
const body = await request.json().catch(() => null);
const parsed = z.object({ email: z.email() }).safeParse(body);
if (!parsed.success) {
return Response.json({ error: 'Invalid email.' }, { status: 400 });
}
const resetUrl = `${env.NEXT_PUBLIC_APP_URL}/reset-password?token=stub`;
// SEEDED #6: fires the Resend send with no rate-limit gate in front of it. The
// declared reset limiter in lib/rate-limit.ts is never reached from this route.
await sendEmail({
to: parsed.data.email,
subject: 'Reset your password',
react: createElement(WelcomeVerification, {
firstName: 'there',
verifyUrl: resetUrl,
}),
idempotencyKey: `reset:${parsed.data.email}:${Date.now()}`,
});
// Opaque success regardless of whether the address exists (enumeration-safe on the
// response, but unthrottled — the defect is the missing gate, not the message).
return Response.json({ ok: true });
};

Now the discovery itself. Two greps surface the gap from the source, and a curl loop confirms it is live. Both belong in the report for different reasons: the grep proves the gap is in the source — auditable, repeatable, wireable into CI later — and the hammer proves the gap is live, that the running target really sends without a ceiling.

findings/006 — ## Location — discovery (greps + the by-hand confirmation)
How it surfaced — the grep discovery, then the by-hand confirmation. Both belong in the report, for different reasons: the grep proves the gap is in the source (auditable, repeatable, CI-able later), the hammer proves the gap is *live* (the running target really sends without a ceiling).
the coverage greps — declared limiters vs. who imports them, and who sends mail
# 1. What limiters are declared, and what actually imports each one?
rg -n 'new Ratelimit' src/lib/rate-limit.ts # -> signIn, signUp, reset declared
rg -rn 'resetLimiter|safeLimit' src/app # -> resetLimiter: zero hits in app/
# 2. Which handlers send mail or otherwise burn money/attack a third party?
rg -rn 'sendEmail|forgetPassword' src/app # -> reset-password/route.ts hits, ungated

Grep 1 is the one that lands the finding: resetLimiter is declared in rate-limit.ts and has zero references anywhere under src/app. From the route’s perspective the limiter is dead code. Grep 2 finds the unthrottled sendEmail call. Reading the route confirms there is no safeLimit import, no limiter call, no 429 path. Then the hammer:

the hammer — twenty identical POSTs against the running target
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/auth/reset-password -H 'content-type: application/json' -d '{"email":"victim@example.com"}'; done

Every request returns 200 {"ok":true} — no 429, no RateLimit-* header, no slow-down, and the seeded Resend path would attempt a send on each one. The opaque success body is actually correct: it is enumeration-safe by response, refusing to confirm whether the address exists. But that correct response shape is exactly what hides the real problem, because the endpoint is unthrottled by behavior. The absence of a 429 across twenty identical submits is the fingerprint, and you only see it if you go looking for it.

One last thing the Location records, so the matrix decisions are explicit rather than silent:

findings/006 — ## Location — what is recorded but not scored here
Recorded as legitimate, not findings: `signInLimiter` and `signUpLimiter` are *also* declared in `rate-limit.ts` and unwired in this target, but the sign-in/sign-up actions route through Better Auth's own `signInEmail`/`signUpEmail` and sign-up is enumeration-closed at the source (`autoSignIn:false`); those are tracked as open rows in the coverage matrix below (gaps are tickets), not folded into this finding, which scopes to the one money-and-third-party endpoint.

signInLimiter and signUpLimiter are declared-but-unwired too, but they belong in the matrix as open rows rather than inside this finding — they have a different story (Better Auth’s own send paths, enumeration-closed sign-up) and folding them in would blur a finding that scopes cleanly to the one money-and-third-party endpoint. Recording them as tickets is the move: gaps become work the next pass picks up, not decisions that quietly vanished.

Frame the harm in user and third-party terms — what an attacker does with the endpoint and who pays. No hedging, no “could potentially”:

findings/006 — ## Consequence
## Consequence
An attacker scripts the endpoint and the product mails on command. Pointed at one address, it is an inbox-bomb: a victim's inbox fills with reset emails from the company's verified domain, drowning real mail and training the recipient (and their provider) to treat the domain as spam — so the domain's deliverability degrades for *every* customer, and the transactional mail the product depends on starts landing in junk. Pointed at a list of guessed addresses, it is account enumeration plus a spam relay: the company's own sending reputation is spent blasting unsolicited mail, and the Resend bill climbs one paid send at a time with no ceiling. There is no throttle and no cost cap between an attacker and the product's mail reputation; the only thing limiting the damage is that nobody has aimed a loop at the endpoint yet.

Three harms, all concrete: the victim’s inbox floods, the company’s whole sending reputation burns down (so every other customer’s transactional mail starts landing in spam), and the Resend bill climbs one paid send at a time with no ceiling. The last sentence names the real state of things — the only thing limiting the damage is that nobody has aimed a loop at the endpoint yet. That is the difference between a consequence and a code-quality note.

The real reach is the dual-keyed safeLimit wrapper, and the finding has to say why per-IP alone is not enough before it names the parts:

findings/006 — ## Fix
## Fix
The senior reach is the dual-keyed `safeLimit` wrapper this lineage already ships the parts for — not per-IP alone. Per-IP-only lets a distributed sender rotate addresses to keep hammering one victim, and it locks out a shared office NAT; the inbox-bomb and enumeration vectors are *per-email* problems, so the email must be a key too. Both gates must pass.
1. **Add a per-email companion limiter** beside the existing per-IP `resetLimiter` in `src/lib/rate-limit.ts` (e.g. `resetEmailLimiter`, prefix `rl:reset:email`, a tight window), so the route can check per-IP and per-email independently. The existing `resetLimiter` becomes the per-IP gate.
2. **Wrap both checks in `safeLimit`** (the fail-open seam from `src/lib/safe-limit.ts`) so a Redis outage logs `rate_limit_unavailable` and lets the reset path stay up rather than 500ing the password-reset flow — the one place the fail-open policy lives (chapter 081, lesson 2, the `safeLimit` seam).
3. **On reject, return a generic 429 with `RateLimit-*` headers** via the route-handler helpers already present — `rateLimitedResponse(result)` (which sets `RateLimit-Limit/Remaining/Reset` + `Retry-After` and an opaque body) from `src/lib/rate-limit-headers.ts`. This is the route-handler path, so headers are available; on the Server-Action twin the budget rides the `Result` instead (the 075 decision: `headers()` is read-only in a Server Action, so actions carry the budget on the `Result` via `rateLimitBudget`/`rateLimited`, not as HTTP headers). Either way the rejection body is the same opaque "Too many attempts" message — no leak of which gate tripped.

Three moves, each leaning on a seam the codebase already ships. The safeLimit fail-open wrapper (chapter 81’s “The abusable-endpoint matrix”) is the one place the fail-open policy lives — wrapping both checks in it means a Redis outage logs rate_limit_unavailable and keeps the reset path up rather than turning an infrastructure blip into a locked-out password flow. The third move splits on surface: this is a route handler, so RateLimit-* headers are available via rateLimitedResponse; on the Server-Action twin the budget rides the Result instead, because headers() is read-only inside a Server Action (the decision from chapter 75’s auth rate-limiting project), so actions carry the budget on the Result. Either way the rejection body is the same opaque “Too many attempts” — the response never leaks which gate tripped. The fix is illustrative, not a diff:

reset-password/route.ts — both gates, fail-open, opaque 429 (illustrative, not committed)
// reset-password/route.ts — both gates, fail-open, opaque 429.
const ip = ipFrom(request);
const byIp = await safeLimit(resetLimiter, 'rl:reset', ip);
const byEmail = await safeLimit(resetEmailLimiter, 'rl:reset:email', parsed.data.email);
if (!byIp.success || !byEmail.success) {
return rateLimitedResponse(byIp.success ? byEmail : byIp);
}
// only now: sendEmail(...)

Coverage is the deliverable, so the finding closes with a row for every abusable endpoint — not just this one. Render it as a plain Markdown table:

Coverage matrix (the lesson-2 deliverable — every abusable endpoint, one row)

Section titled “Coverage matrix (the lesson-2 deliverable — every abusable endpoint, one row)”

| Endpoint category | File | Limiter (prefix) | Key strategy | Covered | |---|---|---|---|---| | Auth — sign-in | src/app/(auth)/sign-in/actions.ts | signInLimiter (rl:signin) declared | per-IP + per-email (dual) | N — declared, unwired (ticket) | | Auth — sign-up | src/app/(auth)/sign-up/actions.ts | signUpLimiter (rl:signup) declared | per-IP + per-email (dual) | N — declared, unwired (ticket) | | Email-sending — password reset | src/app/api/auth/reset-password/route.ts | resetLimiter (rl:reset) declared, + per-email | per-IP and per-email (dual) | N — this finding | | Webhook fan-out — Stripe | src/app/api/webhooks/stripe/route.ts | signature verify (lesson 1) gates receiver | per-tenant on fan-out | N/A receiver verified; fan-out per-tenant is the open row | | Worker — export trigger | src/app/api/exports/trigger/route.ts | bare .limit()bypasses safeLimit | (see bonus finding 10) | N — bypass, not a coverage gap (different rule) |

Gaps are tickets, not silent decisions: the sign-in/sign-up rows are recorded so the next pass closes them; the export-trigger bypass is its own finding (bonus 10, a safeLimit-seam violation, not a missing limiter). Wiring the matrix’s discovery greps into CI is the follow-up (chapter 097), not the fix here.

The matrix is what turns a single finding into coverage. Every abusable endpoint gets a row, and the gaps you are not scoring become explicit tickets instead of silent decisions. Two rows are worth noting: the export-trigger row uses a bare .limit() that bypasses safeLimit — that is its own finding, the safeLimit-seam violation you will score later, not a missing limiter — and the sign-in/sign-up rows are the open tickets the next pass closes. That is the whole point of the matrix: the gap you can see is a ticket; the gap nobody wrote down is the one that ships.

Run the gate for this lesson:

Terminal window
pnpm test:lesson 7

The gate is self-contained — it reads your committed findings/006-rate-limit-password-reset.md by path and asserts the observable shape of the deliverable: all four sections populated with real prose, the rate-limit-coverage rule named with its three triggers and the money/third-party triggers this endpoint hits, the Location naming the discovery grep and both files plus resetLimiter, the Fix naming the dual-keyed safeLimit reach returning a 429, and a coverage matrix with the sign-in and sign-up rows present. It also runs a source-shape probe that the seeded defect is still present — the route still calls sendEmail with no limiter import, and resetLimiter is still declared in rate-limit.ts. A pass proves you documented the gap rather than patching it. Expect green:

pnpm test:lesson 7 — expected
✓ tests/lessons/Lesson 7.test.ts (7 tests)
✓ Lesson 7 — Finding 006 — the four template sections are populated
✓ Lesson 7 — Finding 006 — the rate-limit-coverage rule is named
✓ Lesson 7 — Finding 006 — the location is the gap between two files
✓ Lesson 7 — Finding 006 — the fix names the senior reach
✓ Lesson 7 — Finding 006 — a coverage matrix is attached
✓ Lesson 7 — Finding 006 — the audit stayed read-only (defect still present)
✓ the reset-password route still fires sendEmail with no limiter in front of it
✓ resetLimiter is still declared in rate-limit.ts but imported by nothing under src/app
Test Files 1 passed (1)
Tests 7 passed (7)

The gate checks the finding’s shape, not its judgment. These last few are things only you can confirm by hand — tick each off:

The running-app fingerprint was reproduced: no 429 across twenty repeated submits.
untested
The Fix names per-IP and per-email keying, not per-IP alone.
untested
Both the discovery grep and the manual hammer are documented in the Location section.
untested
The Consequence reads as user-and-third-party harm — inbox-bomb, enumeration, uncapped cost — not a code-quality note.
untested
The recorded triggers name the two this endpoint hits, not a vague “the rule applies.”
untested