Skip to content
Chapter 75Lesson 3

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.

The inspector after a Spam sign-in: ten unauthorized rows with the per-IP remaining counting 9 → 0, the 11th rate_limited, and the rate_limit_rejected row in the structured-log tail.

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.

The eleventh sign-in from the same IP within one minute returns rate_limited; calls 1–10 return unauthorized with the per-IP remaining counting 9 → 0.
tested
The same email hammered across distinct IPs is throttled on the per-email gate — the eleventh cross-IP attempt returns rate_limited with the logged key: 'email:<active-email>', while each per-IP key stays fresh.
tested
The action carries its budget inside the ok payload’s rateLimit field (limit / remaining / reset); it sets no RateLimit-* HTTP headers — those exist only on /api/limit-demo.
tested
The rejection message reads exactly Too many attempts. Please try again later., identical whichever gate tripped; the gate and key surface only in the rate_limit_log row.
tested
With “Force Upstash down” on, fifteen spammed sign-ins all proceed (fail-open) and the structured log shows fifteen rate_limit_unavailable rows.
tested
After the window resets (or counters are cleared), the next sign-in returns unauthorized again with remaining: 9.
tested
src/lib/auth.ts carries rateLimit: { enabled: false } as its only rateLimit entry, and a successful sign-in still lands on /dashboard.
untested
With “Gate after work” off, the eleventh-and-later calls skip the password hash (verify_ms ≈ 0); with it on, every call pays the hash even past the budget.
untested

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.

src/lib/keys.ts is two pure functions: they parse a Headers object and a string into the identifiers the limiter keys on.

src/lib/keys.ts
// 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.

safeLimit is the single seam every gate calls through.

src/lib/safe-limit.ts
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.

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.

1 / 1

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 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.

1 / 1

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().

The last change is one line in src/lib/auth.ts.

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.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 3

The 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:

Click “Reset counters”, then “Spam sign-in” against the active identity with a wrong password. The recent-responses log shows ten 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>.
untested
Run the “Distinct IPs runner” (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.
untested
Toggle “Gate after work” on and spam sign-in: the timing readout shows every call paying the ~80–150ms hash cost even past the budget. Toggle it off and the eleventh-and-later calls collapse to ~5–15ms — the Upstash round-trip alone, no hash.
untested
Toggle “Force Upstash down” on and spam sign-in fifteen times: all proceed and the structured-log tail shows fifteen rate_limit_unavailable rows. Toggle it back off.
untested
After a rejection, click “Reset counters”, then “Send one”: the call returns unauthorized with remaining: 9, confirming a reset releases the budget.
untested
Toggle “Await pending instead of after()” on: the per-call timing readout inflates 5–10ms. Toggle off to return to baseline.
untested
Open 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.
untested
Open /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.
untested

The next lesson reuses these helpers unchanged to gate sign-up.