Gate sign-in per IP and per email
Last lesson built the machinery — the Redis client, the three Ratelimit instances, and an inspector that reads each limiter’s remaining budget — but gated nothing, so the “Remaining tokens” panel never moves.
This lesson points the limiter at real traffic.
You will gate the sign-in action so the eleventh rapid attempt returns an opaque rate_limited Result, and turn Better Auth’s built-in limiter off.
Sign-in is what an attacker hammers with a stolen credential dump, so it gets two gates: one per IP, one per email.
The inspector drives both.
“Spam sign-in” fires eleven wrong-password calls from one IP: the first ten return unauthorized as remaining counts 9 → 0, the eleventh flips to rate_limited.
“Distinct IPs runner” hammers the same email from a fresh synthetic IP each call and is caught on the per-email gate instead.
Flip “Force Upstash down” on and sign-ins keep working while the structured-log tail fills with rate_limit_unavailable rows.
Your mission
Section titled “Your mission”Wrapping sign-in pulls in three helper files: keys.ts, safe-limit.ts, and rate-limit-headers.ts.
The two checks share one limiter under two keys, cheaper first: ip:<addr>, then email:<normalized>, and both must pass.
Per-IP alone misses credential stuffing across a botnet, where every request hits a different IP bucket; per-email alone is a lockout vector, freezing a victim out by spamming their address.
You need both, as an early return on whichever fails.
Both run before auth.api.signInEmail, so a request past the budget never pays the password-hash cost the limiter exists to avoid.
The rejection is identical whichever gate tripped: “IP rate-limited” versus “email rate-limited” leaks which fired, and the email variant confirms an account exists, a free enumeration oracle.
The user sees one opaque string; the real gate and key land only in the operator-only rate_limit_log row.
The budget — limit, remaining, reset — rides inside the success Result, since a Server Action’s headers() is read-only; the literal RateLimit-* headers live only on the route-handler twin at /api/limit-demo.
The limiter fails open: a Redis outage logs an alertable event and lets the request through, so auth stays up when Redis is down.
That decision lives in one place, so switching to fail-closed later is a one-line change.
Disabling Better Auth’s limiter leaves your wrapper as the single enforcement point; two limiters over one surface is a debugging trap.
getClientIp trusts the x-forwarded-for header, correct on Vercel, where the platform sets its first entry to the real client.
normalizeEmail only trims and lowercases — it does not strip +-aliases — and the database lookup normalizes the same way, so the key and the lookup count one identifier.
The sign-up and reset gates come in the next two lessons.
rate_limited; calls 1–10 return unauthorized with the per-IP remaining counting 9 → 0.rate_limited with the logged key: 'email:<active-email>', while each per-IP key stays fresh.ok payload’s rateLimit field (limit / remaining / reset); it sets no RateLimit-* HTTP headers — those exist only on /api/limit-demo.Too many attempts. Please try again later., identical whichever gate tripped; the gate and key surface only in the rate_limit_log row.rate_limit_unavailable rows.unauthorized again with remaining: 9.src/lib/auth.ts carries rateLimit: { enabled: false } as its only rateLimit entry, and a successful sign-in still lands on /dashboard.verify_ms ≈ 0); with it on, every call pays the hash even past the budget.Coding time
Section titled “Coding time”Implement src/lib/keys.ts, src/lib/safe-limit.ts, and src/lib/rate-limit-headers.ts, then wrap src/app/(auth)/sign-in/actions.ts and flip src/lib/auth.ts — against the brief above and the lesson tests.
Open the walkthrough once you’ve made your attempt.
Reference solution and walkthrough
We go in dependency order: the pure helpers, the fail-open wrapper, the budget-and-reject helpers, the action that composes them, and last the one-line auth flip.
The key helpers
Section titled “The key helpers”src/lib/keys.ts is two pure functions: they parse a Headers object and a string into the identifiers the limiter keys on.
// The limiter-key parse helpers. `x-forwarded-for` is the trust boundary: on// Vercel the platform sets it, so the first entry is the real client IP. The// 'unknown' fallback is deliberately loose — strict rejection of a missing/spoofed// forwarded chain is Chapter 081. `normalizeEmail` is trim+lowercase only (no// +-alias stripping); the same normalization runs at the limiter key and the DB// lookup, so an alias and its base address stay distinct keys on purpose.export const getClientIp = (headers: Headers): string => { const forwardedFor = headers.get('x-forwarded-for'); const first = forwardedFor?.split(',')[0]?.trim(); if (first) { return first; } return headers.get('x-real-ip') ?? 'unknown';};
export const normalizeEmail = (email: string): string => email.trim().toLowerCase();x-forwarded-for is a comma-separated chain of every proxy the request crossed, leftmost being the original client; you read that first segment, then fall back to x-real-ip and 'unknown'.
normalizeEmail leaves +-aliases alone because many providers treat alice+spam@example.com and alice@example.com as distinct mailboxes, and the same normalization runs at the database lookup, so the limiter and the lookup never disagree about which account is which.
The fail-open wrapper
Section titled “The fail-open wrapper”safeLimit is the single seam every gate calls through.
import 'server-only';
import type { Ratelimit } from '@upstash/ratelimit';
import { logRateLimit } from '@/lib/rate-limit-log';
// The fail-open wrapper — the one place the fail-open policy lives. On a Redis// outage `limiter.limit` throws; we log `rate_limit_unavailable` and return a// success result so the auth path stays up. Flipping to fail-closed is changing// the returned `success` to false here, once. The `prefix` is a param because// `Ratelimit.prefix` is `protected readonly` in @upstash/ratelimit 2.0.8 (reading// `limiter.prefix` from outside the class is TS2445); call sites pass the limiter's// prefix literal alongside it.export type RateLimitResult = Awaited<ReturnType<Ratelimit['limit']>>;
export const safeLimit = async ( limiter: Ratelimit, prefix: string, key: string,): Promise<RateLimitResult> => { try { return await limiter.limit(key); } catch { await logRateLimit({ event: 'rate_limit_unavailable', limiter: prefix, key, }); return { success: true, limit: 0, remaining: 0, reset: 0, pending: Promise.resolve(), }; }};RateLimitResult is derived from the library via Awaited<ReturnType<Ratelimit['limit']>> rather than hand-written, so it tracks the real return shape and won’t rot the day Upstash adds a field.
The prefix literal 'rl:signin' that the call site passes alongside the limiter is what lands in the outage log, so an operator can see which surface lost Redis.
The budget and reject helpers
Section titled “The budget and reject helpers”rate-limit-headers.ts holds four exports with two audiences: the action path and the route-handler twin.
import 'server-only';
import { logRateLimit } from '@/lib/rate-limit-log';import { err, type Result } from '@/lib/result';import type { RateLimitResult } from '@/lib/safe-limit';
// `reset` from the library is a Unix ms timestamp; the budget and headers carry it// as delta-seconds via Math.ceil((reset - Date.now()) / 1000) — raw ms is a bug.//// The budget rides the action `Result` (no HTTP headers on the action path —// headers() is read-only in a Server Action). `RateLimit-*` headers + Retry-After +// the JSON 429 body exist only on the route-handler twin (`/api/limit-demo`),// present for parity. `rateLimited` is the action reject helper: it logs the honest// `rate_limit_rejected` event (gate + key) and returns the same opaque message// regardless of which gate tripped — no information leak.export type RateLimitBudget = { limit: number; remaining: number; reset: number;};
export const rateLimitBudget = (r: RateLimitResult): RateLimitBudget => ({ limit: r.limit, remaining: r.remaining, reset: Math.ceil((r.reset - Date.now()) / 1000),});
export const rateLimitHeaders = ( r: RateLimitResult,): Record<string, string> => ({ 'RateLimit-Limit': String(r.limit), 'RateLimit-Remaining': String(r.remaining), 'RateLimit-Reset': String(Math.ceil((r.reset - Date.now()) / 1000)),});
export const rateLimited = async ( r: RateLimitResult, gate: 'ip' | 'email', key: string,): Promise<Result<never>> => { await logRateLimit({ event: 'rate_limit_rejected', limiter: gate, key, remaining: r.remaining, reset: r.reset, }); return err('rate_limited', 'Too many attempts. Please try again later.');};
export const rateLimitedResponse = (r: RateLimitResult): Response => Response.json( { error: 'Too many attempts. Please try again later.' }, { status: 429, headers: { ...rateLimitHeaders(r), 'Retry-After': String(Math.ceil((r.reset - Date.now()) / 1000)), }, }, );Imports logRateLimit and the Result helpers, and re-uses the library-derived RateLimitResult from safe-limit.
import 'server-only';
import { logRateLimit } from '@/lib/rate-limit-log';import { err, type Result } from '@/lib/result';import type { RateLimitResult } from '@/lib/safe-limit';
// `reset` from the library is a Unix ms timestamp; the budget and headers carry it// as delta-seconds via Math.ceil((reset - Date.now()) / 1000) — raw ms is a bug.//// The budget rides the action `Result` (no HTTP headers on the action path —// headers() is read-only in a Server Action). `RateLimit-*` headers + Retry-After +// the JSON 429 body exist only on the route-handler twin (`/api/limit-demo`),// present for parity. `rateLimited` is the action reject helper: it logs the honest// `rate_limit_rejected` event (gate + key) and returns the same opaque message// regardless of which gate tripped — no information leak.export type RateLimitBudget = { limit: number; remaining: number; reset: number;};
export const rateLimitBudget = (r: RateLimitResult): RateLimitBudget => ({ limit: r.limit, remaining: r.remaining, reset: Math.ceil((r.reset - Date.now()) / 1000),});
export const rateLimitHeaders = ( r: RateLimitResult,): Record<string, string> => ({ 'RateLimit-Limit': String(r.limit), 'RateLimit-Remaining': String(r.remaining), 'RateLimit-Reset': String(Math.ceil((r.reset - Date.now()) / 1000)),});
export const rateLimited = async ( r: RateLimitResult, gate: 'ip' | 'email', key: string,): Promise<Result<never>> => { await logRateLimit({ event: 'rate_limit_rejected', limiter: gate, key, remaining: r.remaining, reset: r.reset, }); return err('rate_limited', 'Too many attempts. Please try again later.');};
export const rateLimitedResponse = (r: RateLimitResult): Response => Response.json( { error: 'Too many attempts. Please try again later.' }, { status: 429, headers: { ...rateLimitHeaders(r), 'Retry-After': String(Math.ceil((r.reset - Date.now()) / 1000)), }, }, );rateLimitBudget is what rides the success Result. A 60-second window must read ~60, not the raw 13-digit timestamp the library hands you — shipping the raw ms is the documented bug.
import 'server-only';
import { logRateLimit } from '@/lib/rate-limit-log';import { err, type Result } from '@/lib/result';import type { RateLimitResult } from '@/lib/safe-limit';
// `reset` from the library is a Unix ms timestamp; the budget and headers carry it// as delta-seconds via Math.ceil((reset - Date.now()) / 1000) — raw ms is a bug.//// The budget rides the action `Result` (no HTTP headers on the action path —// headers() is read-only in a Server Action). `RateLimit-*` headers + Retry-After +// the JSON 429 body exist only on the route-handler twin (`/api/limit-demo`),// present for parity. `rateLimited` is the action reject helper: it logs the honest// `rate_limit_rejected` event (gate + key) and returns the same opaque message// regardless of which gate tripped — no information leak.export type RateLimitBudget = { limit: number; remaining: number; reset: number;};
export const rateLimitBudget = (r: RateLimitResult): RateLimitBudget => ({ limit: r.limit, remaining: r.remaining, reset: Math.ceil((r.reset - Date.now()) / 1000),});
export const rateLimitHeaders = ( r: RateLimitResult,): Record<string, string> => ({ 'RateLimit-Limit': String(r.limit), 'RateLimit-Remaining': String(r.remaining), 'RateLimit-Reset': String(Math.ceil((r.reset - Date.now()) / 1000)),});
export const rateLimited = async ( r: RateLimitResult, gate: 'ip' | 'email', key: string,): Promise<Result<never>> => { await logRateLimit({ event: 'rate_limit_rejected', limiter: gate, key, remaining: r.remaining, reset: r.reset, }); return err('rate_limited', 'Too many attempts. Please try again later.');};
export const rateLimitedResponse = (r: RateLimitResult): Response => Response.json( { error: 'Too many attempts. Please try again later.' }, { status: 429, headers: { ...rateLimitHeaders(r), 'Retry-After': String(Math.ceil((r.reset - Date.now()) / 1000)), }, }, );rateLimited is the action’s reject helper. Defining the user-safe message here once makes every rejection byte-identical to the user, while the logged row still records which gate fired.
import 'server-only';
import { logRateLimit } from '@/lib/rate-limit-log';import { err, type Result } from '@/lib/result';import type { RateLimitResult } from '@/lib/safe-limit';
// `reset` from the library is a Unix ms timestamp; the budget and headers carry it// as delta-seconds via Math.ceil((reset - Date.now()) / 1000) — raw ms is a bug.//// The budget rides the action `Result` (no HTTP headers on the action path —// headers() is read-only in a Server Action). `RateLimit-*` headers + Retry-After +// the JSON 429 body exist only on the route-handler twin (`/api/limit-demo`),// present for parity. `rateLimited` is the action reject helper: it logs the honest// `rate_limit_rejected` event (gate + key) and returns the same opaque message// regardless of which gate tripped — no information leak.export type RateLimitBudget = { limit: number; remaining: number; reset: number;};
export const rateLimitBudget = (r: RateLimitResult): RateLimitBudget => ({ limit: r.limit, remaining: r.remaining, reset: Math.ceil((r.reset - Date.now()) / 1000),});
export const rateLimitHeaders = ( r: RateLimitResult,): Record<string, string> => ({ 'RateLimit-Limit': String(r.limit), 'RateLimit-Remaining': String(r.remaining), 'RateLimit-Reset': String(Math.ceil((r.reset - Date.now()) / 1000)),});
export const rateLimited = async ( r: RateLimitResult, gate: 'ip' | 'email', key: string,): Promise<Result<never>> => { await logRateLimit({ event: 'rate_limit_rejected', limiter: gate, key, remaining: r.remaining, reset: r.reset, }); return err('rate_limited', 'Too many attempts. Please try again later.');};
export const rateLimitedResponse = (r: RateLimitResult): Response => Response.json( { error: 'Too many attempts. Please try again later.' }, { status: 429, headers: { ...rateLimitHeaders(r), 'Retry-After': String(Math.ceil((r.reset - Date.now()) / 1000)), }, }, );The route-handler twin, used only by /api/limit-demo: rateLimitHeaders builds the literal RateLimit-* set, and rateLimitedResponse wraps a JSON 429 with those headers plus Retry-After. A Server Action can’t set response headers, so these exist only so the demo route shows the real HTTP shape.
The action carries its budget inside the Result via rateLimitBudget and rejects through rateLimited; the two header functions exist only so the demo route can produce the literal RateLimit-* headers and a real 429, output a Server Action cannot.
The sign-in action
Section titled “The sign-in action”The action that composes everything — the shape every other gated endpoint in the project copies.
'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 { signInLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { safeNext } from '@/lib/redirects';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.signInEmail`. 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 signInAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignInSchema.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(signInLimiter, 'rl:signin', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( signInLimiter, 'rl:signin', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.signInEmail({ body: { email, password: parsed.data.password }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); const next = safeNext(parsed.data.next); return ok({ redirectTo: next ?? '/dashboard', rateLimit: rateLimitBudget(ipLimit), });};The strictObject rejects any field the form shouldn’t send; the email is trimmed, lowercased, then piped to z.email(), so the normalized address flows downstream.
'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 { signInLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { safeNext } from '@/lib/redirects';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.signInEmail`. 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 signInAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignInSchema.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(signInLimiter, 'rl:signin', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( signInLimiter, 'rl:signin', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.signInEmail({ body: { email, password: parsed.data.password }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); const next = safeNext(parsed.data.next); return ok({ redirectTo: next ?? '/dashboard', rateLimit: rateLimitBudget(ipLimit), });};The two gate keys: the client IP off the request headers via getClientIp, and the already-normalized email from the parsed data.
'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 { signInLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { safeNext } from '@/lib/redirects';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.signInEmail`. 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 signInAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignInSchema.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(signInLimiter, 'rl:signin', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( signInLimiter, 'rl:signin', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.signInEmail({ body: { email, password: parsed.data.password }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); const next = safeNext(parsed.data.next); return ok({ redirectTo: next ?? '/dashboard', rateLimit: rateLimitBudget(ipLimit), });};The dual gate, before any credential work. Per-IP runs first (cheaper), then per-email; the first to fail returns rateLimited(...) and stops, which is what enforces “both must pass”.
'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 { signInLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { safeNext } from '@/lib/redirects';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.signInEmail`. 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 signInAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignInSchema.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(signInLimiter, 'rl:signin', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( signInLimiter, 'rl:signin', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.signInEmail({ body: { email, password: parsed.data.password }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); const next = safeNext(parsed.data.next); return ok({ redirectTo: next ?? '/dashboard', rateLimit: rateLimitBudget(ipLimit), });};Only once both gates pass do we call auth.api.signInEmail. A wrong password or unverified email throws, and mapAuthError translates the Better Auth error into the right Result code. This branch still flushes both pending promises first.
'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 { signInLimiter } from '@/lib/rate-limit';import { type RateLimitBudget, rateLimitBudget, rateLimited,} from '@/lib/rate-limit-headers';import { safeNext } from '@/lib/redirects';import { err, ok, type Result } from '@/lib/result';import { safeLimit } from '@/lib/safe-limit';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
// Gate before work, dual-keyed: per-IP then per-email (cheaper first), both// through `safeLimit`, both before `auth.api.signInEmail`. 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 signInAction = async ( _state: Result<{ redirectTo: string; rateLimit: RateLimitBudget }> | null, formData: FormData,): Promise<Result<{ redirectTo: string; rateLimit: RateLimitBudget }>> => { const parsed = SignInSchema.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(signInLimiter, 'rl:signin', `ip:${ip}`); if (!ipLimit.success) { return rateLimited(ipLimit, 'ip', ip); }
const emailLimit = await safeLimit( signInLimiter, 'rl:signin', `email:${email}`, ); if (!emailLimit.success) { return rateLimited(emailLimit, 'email', email); }
try { await auth.api.signInEmail({ body: { email, password: parsed.data.password }, }); } catch (e) { after(ipLimit.pending); after(emailLimit.pending); return mapAuthError(e); }
after(ipLimit.pending); after(emailLimit.pending); const next = safeNext(parsed.data.next); return ok({ redirectTo: next ?? '/dashboard', rateLimit: rateLimitBudget(ipLimit), });};On success, after(pending) flushes each limiter’s analytics write off the response path rather than blocking the user (awaiting costs 5–10ms per call). We return ok with redirectTo — not redirect() — keeping the useActionState shape this surface uses, so the form navigates client-side while the per-IP budget rides along.
Two choices carry the design.
The gates run before signInEmail because verifying a bcrypt-class hash is deliberately slow, and an attacker past the budget should pay nothing for it.
And after() sits on both branches because the analytics write should flush whether or not the credentials were good; for the full story, see Inline, then after().
Turn off the built-in limiter
Section titled “Turn off the built-in limiter”The last change is one line in src/lib/auth.ts.
// The app-level limiters are the single enforcement point; leaving the built-in // on means two limiters competing over the same surface. rateLimit: { enabled: false },Better Auth ships an in-memory limiter of its own.
Left on, two limiters with different budgets, keys, and storage race on the same sign-in surface, and a throttled request never tells you which one fired.
Pointing Better Auth’s built-in at Upstash through its secondaryStorage adapter is the road not taken, covered in Rate-limiting the auth endpoints; the application-wrapper seam wins because it gives one budget, one opaque message, and one fail-open policy across every endpoint, auth and non-auth alike.
The exact return shape your safeLimit and rateLimitBudget helpers wrap: success, limit, remaining, and reset as a Unix-ms timestamp.
The built-in limiter you turn off here, plus the secondary-storage-Redis path this lesson chose not to take.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3The suite drives your real helpers — getClientIp, normalizeEmail, safeLimit, rateLimited, and rateLimitBudget — in the same order the sign-in action runs them: per-IP gate, then per-email gate, both before any credential work.
It uses a deterministic in-test limiter, so it never waits on a live Upstash window, and it reads back the rate_limit_log rows your helpers write, so it needs DATABASE_URL set just as the inspector does.
Eleven tests cover the chapter’s behaviors: the per-IP and per-email counts, the carried budget, the byte-identical opaque message over honest rows, and failing open when the limiter throws.
The tests can’t reach auth.ts config, drive a real login, or read the inspector’s timing readout, so confirm the rest by hand on /inspector:
unauthorized rows with the per-IP remaining declining 9 → 0, then an eleventh rate_limited row carrying the opaque message and remaining: 0; the “Remaining tokens” panel reads signin → ip:<addr> → 0/10, and the structured-log tail shows the honest rate_limit_rejected row keyed on ip:<addr>.spoof-ip-runner). Each iteration uses a fresh synthetic ip: key but the same email:<active-email> key, so every per-IP key stays fresh while the per-email gate counts down; the eleventh returns rate_limited with the logged key: 'email:<active-email>'. This cross-IP per-email catch is the chapter’s load-bearing result.rate_limit_unavailable rows. Toggle it back off.unauthorized with remaining: 9, confirming a reset releases the budget.src/lib/auth.ts and confirm rateLimit: { enabled: false } is the only rateLimit entry; then sign in for real as alice and confirm you still land on /dashboard./api/limit-demo repeatedly: after the budget is spent it returns a real 429 with literal RateLimit-* headers, a Retry-After header, and the opaque JSON body — the one place in the project those HTTP headers exist.The next lesson reuses these helpers unchanged to gate sign-up.