Gate sign-up per-IP
Sign-in is gated; now you gate the other entry into your app, sign-up.
One per-IP limit stops a single host from mass-registering accounts, and the gate counts hosts, not emails.
Every call still carries its rate-limit budget back on the Result.
Watch it on /inspector.
Spam sign-up fires six sign-up calls with distinct random-suffix emails: the first five pass and the sixth comes back rate_limited, logged under the key ip:<addr>.
The Remaining tokens panel then reads signup → ip:<addr> → 0/5.
Your mission
Section titled “Your mission”Sign-in keyed two gates, IP and email, because each names a real account under attack. Sign-up is different: the attacker types the email, so a script can rotate a fresh address onto every request and never hit the same bucket twice. The one identity it can’t rotate is the request’s IP, so sign-up gets a single per-IP gate and nothing else.
Everything else already shipped earlier in this chapter: the signUpLimiter (five per ten minutes), safeLimit, and the reject and budget helpers. Reuse them.
Wrap one gate around auth.api.signUpEmail: check it before the call, return rejections through the opaque rateLimited(...), put the budget on the success payload’s rateLimit field, keep safeLimit’s fail-open policy, and flush pending analytics through after().
Don’t key on the email; the test that five distinct emails all pass holds you to that.
signUpAction keeps the (state, formData) signature and returns Result<{ redirectTo: string; rateLimit: RateLimitBudget }>.
On success it returns ok({ redirectTo: '/verify-email?email=…', … }) rather than calling redirect(), and the form navigates off state.data.redirectTo.
The budget rides the payload because a Server Action’s headers() is read-only, so it can’t send RateLimit-* headers.
rate_limited, with a structured-log row keyed key: 'ip:<addr>' on the ip gate.{ limit, remaining, reset } on its rateLimit field; the rejection returns the opaque Too many attempts. Please try again later.rate_limit_unavailable event.validation result before the gate runs, so it never burns a token.pending analytics flush through after() rather than being awaited on the response path.Coding time
Section titled “Coding time”Fill in src/app/(auth)/sign-up/actions.ts: one per-IP gate before the sign-up call, the budget on the success Result. Try it first.
Reference solution and walkthrough
The whole file:
'use server';
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 { signUpLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignUpSchema = z.strictObject({ name: z.string().min(1).max(80), email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(12),});
// Gate before work, per-IP only: one limiter check on `ip:` before// `auth.api.signUpEmail`. Keying on the email is wrong here — the address is the// attacker's choice, so a per-email gate lets one host cycle fresh addresses past// it. The budget rides the success `Result` (no HTTP headers — headers() is// read-only here); the reject path returns the opaque `rateLimited(...)`.// `pending` analytics flush via `after()`, never awaited on the path.export const signUpAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignUpSchema.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 ipLimit = await safeLimit(signUpLimiter, 'rl:signup', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const { name, email, password } = parsed.data; try { // No taken-email branch: under autoSignIn:false a duplicate returns generic // success, so enumeration is closed at the source (Ch053 L1). await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { after(ipLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); return ok({ redirectTo: `/verify-email?email=${encodeURIComponent(email)}`, rateLimit: rateLimitBudget(ipLimit), });};The four moves, in order:
export const signUpAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignUpSchema.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 ipLimit = await safeLimit(signUpLimiter, 'rl:signup', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const { name, email, password } = parsed.data; try { await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { after(ipLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); return ok({ redirectTo: `/verify-email?email=${encodeURIComponent(email)}`, rateLimit: rateLimitBudget(ipLimit), });};Parse before gating. A malformed body fails safeParse and returns a validation result above the gate, so a bad request can’t burn a token.
export const signUpAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignUpSchema.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 ipLimit = await safeLimit(signUpLimiter, 'rl:signup', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const { name, email, password } = parsed.data; try { await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { after(ipLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); return ok({ redirectTo: `/verify-email?email=${encodeURIComponent(email)}`, rateLimit: rateLimitBudget(ipLimit), });};The single gate: one safeLimit on signUpLimiter, keyed ip:${ip} and nothing else. The per-IP-only decision made concrete — the email never enters the key.
export const signUpAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignUpSchema.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 ipLimit = await safeLimit(signUpLimiter, 'rl:signup', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const { name, email, password } = parsed.data; try { await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { after(ipLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); return ok({ redirectTo: `/verify-email?email=${encodeURIComponent(email)}`, rateLimit: rateLimitBudget(ipLimit), });};When the gate trips, hand off to rateLimited(result, gate, key). Pass the bare ip, not ip:${ip}; the helper composes the logged key itself. The message is the same opaque Too many attempts. Please try again later. as every gate, so the response can’t leak which limit fired.
export const signUpAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignUpSchema.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 ipLimit = await safeLimit(signUpLimiter, 'rl:signup', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const { name, email, password } = parsed.data; try { await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { after(ipLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); return ok({ redirectTo: `/verify-email?email=${encodeURIComponent(email)}`, rateLimit: rateLimitBudget(ipLimit), });};The success path carries the budget and flushes analytics off-path. rateLimitBudget(ipLimit) rides limit / remaining / reset home on the ok payload, since this action can’t set headers. after(ipLimit.pending) ships the analytics write after the response leaves; it sits on the catch branch too, so a failed sign-up still records without making the user wait.
Two decisions worth naming.
No “email already taken” branch. With autoSignIn: false, signing up with an existing address returns the same generic success a new one does, so an attacker can’t tell registered emails from fresh ones by watching responses. Closing enumeration at the source beats a friendlier error; the reasoning lives in Password sign-up.
A new gate costs almost nothing. Beside the sign-in action the diff is small: signUpLimiter for signInLimiter, the same helpers, one gate instead of two. Once the seam exists, protecting another action is a few lines, not a redesign.
The primitives this lesson reuses — dual-keying, fail-open, the budget on the Result, and after() — are covered in Build the dual-keyed sign-in gate.
Moment of truth
Section titled “Moment of truth”Run the suite:
pnpm test:lesson 4 ✓ tests/lessons/Lesson 4.test.ts (6 tests)
Test Files 1 passed (1) Tests 6 passed (6)The tests drive the same helpers as the action but can’t see the inspector, so confirm the rest by hand on /inspector:
rate_limited.key: 'ip:<addr>' and the opaque Too many attempts. Please try again later. — never the address or which gate fired.signup → ip:<addr> → 0/5 after the run.rate_limit_unavailable. Toggle it back off.