Gate the password reset per-IP and per-email
This is the last gate. You wire the password-reset action with two keys, per-IP and per-email, just like sign-in. But the per-email key earns its place for a new reason: every accepted reset sends real mail. A verification link goes out through Resend, on your domain, on your dime, to the targeted address. So the cost of abuse lands on a third party, the person whose inbox is filling up, plus your sender reputation and your email budget. The per-IP key stops one host hammering; the per-email key protects the victim’s address even as an attacker rotates hosts, and surviving that IP switch is the whole point.
You can watch that on /inspector. “Spam reset” fires four requests at eve@example.com, each from a different synthetic IP. The first three return ok; the fourth flips to rate_limited with the opaque Too many attempts. Please try again later., logged as limiter: 'reset', key: 'email:eve@example.com'. Now run “Distinct IPs runner (reset)” to hit one more never-seen IP: still rejected, because the per-email gate keys on the victim, not the requester. The mock-email counter ticks up only for the three that got through.
Your mission
Section titled “Your mission”Gate the password-reset request the same way you gated sign-in: parse, check per-IP, check per-email, then run the send. This is the project’s second dual-keyed endpoint, and the per-email gate is the one that matters. Every reset that clears both gates sends one real email, so unchecked abuse floods a third party’s inbox and runs up your Resend cost. Because per-IP alone misses a campaign spread across many hosts, the gate keyed to the email address is what stands between an IP-rotating attacker and a victim’s inbox.
That same property shapes how you demonstrate it. Both gates draw on the project’s tightest budget, and per-IP is checked first, so a burst from one IP trips the per-IP gate before the per-email gate is ever consulted. To watch the per-email gate fire, you have to spread the resets across distinct IPs, which is what the inspector’s “Spam reset” does.
The carried rules are the ones the sign-in gate established: both checks run before auth.api.requestPasswordReset so a blocked attacker never triggers a send, the rejection returns through the opaque rateLimited(...) helper so it reads the same whichever gate tripped, safeLimit keeps the fail-open policy, and pending analytics flush through after(). One shape difference: reset returns ok({ sent: true }), a marker rather than a redirect, because the form renders an enumeration-uniform confirmation in place. Real delivery stays mocked here so the inspector can count sends; the live Resend path is The welcome email send path.
eve@example.com across distinct IPs return rate_limited on the fourth, with limiter: 'reset' and the logged key: 'email:eve@example.com' — every per-IP bucket stays fresh, so the per-email gate is what trips.eve@example.com is still rate_limited on the per-email gate — the gate survives the IP change.ok({ sent: true }); a rejection returns the opaque rate_limited message, with the gate and key surfacing only in the rate_limit_log row.rate_limit_unavailable rows.Coding time
Section titled “Coding time”Fill in src/app/(auth)/reset/actions.ts against the brief and the tests, reusing resetLimiter, safeLimit, and the rateLimited reject helper: per-IP gate then per-email gate, both before the reset request, the marker on the success Result. Try it before you open the solution.
Reference solution and walkthrough
The whole file. Set it next to the sign-in action from Gate sign-in with dual-keying and the import surface is identical: resetLimiter in place of signInLimiter, the same getClientIp, safeLimit, and rateLimited.
'use server';
11 collapsed lines
import { headers } from 'next/headers';import { after } from 'next/server';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { getClientIp } from '@/lib/keys';import { resetLimiter } from '@/lib/rate-limit';import { rateLimited } from '@/lib/rate-limit-headers';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const ResetSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()),});
8 collapsed lines
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.requestPasswordReset`. The per-email gate// is the load-bearing one here — it survives an IP switch, so a campaign against// one victim's address can't flood their inbox (and our Resend cost) by rotating// hosts. Tightest budget in the project (3/15m). Reset has no redirect: the form// renders an enumeration-uniform confirmation in place, so the ok payload is a// marker, not a navigation. `pending` analytics flush via `after()`, never awaited// on the path.export const resetAction = async ( _state: Result<{ sent: true }> | null, formData: FormData,): Promise<Result<{ sent: true }>> => { const parsed = ResetSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const ip = getClientIp(await headers()); const email = parsed.data.email;
const ipLimit = await safeLimit(resetLimiter, 'rl:reset', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( resetLimiter, 'rl:reset', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { // Enumeration-uniform by default: an unknown email returns success without // sending. `redirectTo` is only the link target baked into the email; the // token-consume page is named-not-built — the project verifies the gate. await auth.api.requestPasswordReset({ body: { email, redirectTo: '/sign-in' }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); return ok({ sent: true });};Walk the four moves that matter, in order.
export const resetAction = async ( _state: Result<{ sent: true }> | null, formData: FormData,): Promise<Result<{ sent: true }>> => { const parsed = ResetSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const ip = getClientIp(await headers()); const email = parsed.data.email;
const ipLimit = await safeLimit(resetLimiter, 'rl:reset', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( resetLimiter, 'rl:reset', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.requestPasswordReset({ body: { email, redirectTo: '/sign-in' }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); return ok({ sent: true });};Parse the form first and return on failure before any gate runs. The schema trims and lowercases the email, then pipes it to z.email(), so the normalized address is what flows into the per-email key, and a malformed email returns a validation result without burning a token. This early return is the one part of the wired action a test can call without a live request scope, which is why the suite probes it.
export const resetAction = async ( _state: Result<{ sent: true }> | null, formData: FormData,): Promise<Result<{ sent: true }>> => { const parsed = ResetSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const ip = getClientIp(await headers()); const email = parsed.data.email;
const ipLimit = await safeLimit(resetLimiter, 'rl:reset', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( resetLimiter, 'rl:reset', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.requestPasswordReset({ body: { email, redirectTo: '/sign-in' }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); return ok({ sent: true });};The dual gate, before the send. Per-IP runs first because it’s the cheaper rejection: a noisy host is stopped without touching the shared per-email bucket. Both go through safeLimit on the same resetLimiter with the same 'rl:reset' prefix; only the keys differ (ip: versus email:). The first to fail returns the opaque rateLimited(...) and stops, which is what enforces “both must pass.”
export const resetAction = async ( _state: Result<{ sent: true }> | null, formData: FormData,): Promise<Result<{ sent: true }>> => { const parsed = ResetSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const ip = getClientIp(await headers()); const email = parsed.data.email;
const ipLimit = await safeLimit(resetLimiter, 'rl:reset', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( resetLimiter, 'rl:reset', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.requestPasswordReset({ body: { email, redirectTo: '/sign-in' }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); return ok({ sent: true });};Only once both gates pass do we call auth.api.requestPasswordReset. This is the line that sends mail, so it sits strictly after the gates, and a blocked attacker never reaches it. Better Auth returns success without sending for an address it doesn’t recognize, so the response is uniform whether or not the account exists; a thrown error is translated by mapAuthError.
export const resetAction = async ( _state: Result<{ sent: true }> | null, formData: FormData,): Promise<Result<{ sent: true }>> => { const parsed = ResetSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const ip = getClientIp(await headers()); const email = parsed.data.email;
const ipLimit = await safeLimit(resetLimiter, 'rl:reset', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( resetLimiter, 'rl:reset', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.requestPasswordReset({ body: { email, redirectTo: '/sign-in' }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); return ok({ sent: true });};Flush analytics off-path and return the marker. after(pending) hands each limiter’s analytics write to Next.js’s after(), so it runs once the response is on its way instead of blocking the user, and it sits on the catch branch too. The success payload is ok({ sent: true }), not a redirect: reset has no destination, so the form shows its confirmation in place.
A few decisions worth naming.
Why the budget is the tightest in the project. Sign-in is ten per minute, sign-up five per ten minutes, reset three per fifteen minutes. The budget tracks two things: how often a legitimate user acts, and how much each abusive call costs. Reset is at the extreme of both: nobody requests four password resets in a quarter hour, and every accepted request sends a real email you’d rather not pay for. Low legitimate frequency plus high abuse cost gives the smallest window.
Why after(pending) sits on the catch branch too. The analytics write is bookkeeping about the limiter, not about the credential outcome, so it flushes whether or not requestPasswordReset throws, which is why it appears on both branches. It goes through after() rather than being awaited inline because awaiting the analytics round-trip on the response path would add latency to every reset for no user benefit; for the full story on after(), see Inline, then after().
Why the ok is a marker, not navigation. An unknown email and a known one return the identical ok({ sent: true }), and the form renders one enumeration-uniform confirmation in place: “if that address exists, a link is on its way.” The redirectTo: '/sign-in' in the call is not navigation for the requester; it’s only the link target baked into the email the recipient receives. Consuming that token on a reset-completion page is named-not-built here: this project verifies the gate, not the full reset flow.
For the parts this lesson reuses but doesn’t own — dual-keying, gate-before-work, the opaque message, and safeLimit’s fail-open — see Gate sign-in with dual-keying where they were first wired, and the Upstash primitives in the rate-limiting chapter’s dual-keying lesson.
The sliding-window algorithm behind resetLimiter's 3-per-15-minutes budget, and what .limit() hands back.
The requestPasswordReset server API this gate wraps, including its enumeration-uniform reset behaviour.
Moment of truth
Section titled “Moment of truth”Run the lesson’s suite:
pnpm test:lesson 5The suite drives your real helpers, composed the way the reset action composes them, against a deterministic in-test limiter, so the run never waits on a live Upstash window. It calls resetAction directly to confirm the parse early-return yields a validation result, and it reads back the honest rate_limit_log rows, so it needs DATABASE_URL set. All seven tests pass.
✓ tests/lessons/Lesson 5.test.ts (7 tests)
Test Files 1 passed (1) Tests 7 passed (7)The tests drive the helpers but can’t reach the inspector or force a Redis outage. Confirm the rest by hand on /inspector:
ok resets against eve@example.com and a fourth rate_limited row carrying the opaque Too many attempts. Please try again later. and key: email:eve@example.com.rl:reset → email:eve@example.com → 0/3, while the rl:reset → ip:<addr> row stays fresh — proof the per-email gate, not the per-IP gate, did the blocking.rate_limited on the per-email gate. This cross-host survival is the chapter’s load-bearing reset result.3 after the spam run — the three accepted resets each sent one, the blocked fourth sent nothing.rate_limit_rejected row keyed on email:eve@example.com — the gate and key the opaque user message hides.rate_limit_unavailable rows (the fail-open path). Toggle it back off.With reset gated, the auth surface is fully covered: sign-in, sign-up, and reset, with Better Auth’s built-in limiter off and your wrapper the single enforcement point. The same shape — parse, gate-before-work, opaque-reject, fail-open — transfers to any future endpoint with a different key and budget.