Rate Limiting and Input Validation
- Explain why rate limiting and input validation are separate concerns from authentication and authorization
- Describe the documented limitation of an in-memory fixed-window rate limiter and when it needs to be replaced with a shared store
- Choose the correct rate-limit key (IP vs user id) based on whether a route is authenticated
- Write a Zod schema with both type and bound constraints (length, range, enum) for a request body
- Explain why safeParse is preferred over parse for handling untrusted input without throwing
- Order request-handling gates (rate limit, parse, validate, authorize) from cheapest to most expensive to reject abuse early
Two Different Problems, Two Different Tools
Lesson 22 audited who is allowed to call a route. This lesson covers two separate failure modes that auth does not solve: a legitimate, authenticated caller sending requests too fast, and any caller -- authenticated or not -- sending well-formed JSON that is still garbage. The reputation dashboard handles both with the same two tools in every route that needs them: an in-memory rate limiter, and Zod schemas validated before any database call.
The Rate Limiter: Simple on Purpose
lib/rate-limit.ts is a fixed-window limiter backed by a single in-memory Map:
export function rateLimit(
key: string,
limit: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
const resetAt = now + windowMs;
buckets.set(key, { count: 1, resetAt });
return { success: true, limit, remaining: limit - 1, resetAt };
}
if (existing.count >= limit) {
return { success: false, limit, remaining: 0, resetAt: existing.resetAt };
}
existing.count += 1;
return { success: true, limit, remaining: limit - existing.count, resetAt: existing.resetAt };
}
The file's own comment is upfront about its ceiling: "This is intentionally simple and good enough for a single-instance deployment or local dev. It will NOT coordinate across multiple serverless instances/regions." Each serverless instance gets its own Map, so a caller spread across instances could exceed the nominal limit. That is a real, documented limitation, not an oversight -- the fix when it matters is swapping this module for a shared store like Upstash Redis or Vercel KV behind the same rateLimit() call signature, which is exactly the kind of thing worth flagging explicitly in code rather than leaving as a silent gap, the same way this file does.
A background interval sweeps expired buckets every 60 seconds so the Map does not grow without bound on a long-running instance:
setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}, 60_000).unref?.();
Keying by IP vs Keying by User
The two routes that call rateLimit() key it differently, deliberately. app/api/reviews/ingest/route.ts -- the endpoint a review-platform poller would hit, unauthenticated by IP -- keys on IP address:
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const limit = rateLimit(`ingest:${ip}`, 30, 60_000);
if (!limit.success) {
return NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((limit.resetAt - Date.now()) / 1000)) } },
);
}
app/api/reviews/[id]/respond/route.ts keys on the authenticated user's id instead, and only runs the check after confirming a user exists:
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const limit = rateLimit(`respond:${user.id}`, 20, 60_000);
if (!limit.success) {
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 });
}
IP-keying is the right call for an unauthenticated ingestion endpoint -- there is no user id yet to key on. User-keying is the right call once someone is signed in, because IP is a worse signal behind shared NATs and mobile carriers, and the goal is capping one account's behavior, not one network's. Both return 429 Too Many Requests, the correct status for "you are who you say you are, you're just doing it too fast" -- distinct from the 401/403 pair from the previous lesson.
Validate Before You Touch the Database
Every route that accepts a body defines a Zod schema and checks it before any query runs. The ingestion endpoint's schema is the strictest in the app, because it is the one route that (in a real deployment) would face external, semi-trusted input from a review-platform integration:
const ingestSchema = z.object({
businessId: z.string().uuid(),
source: z.enum(["google", "yelp", "facebook"]),
externalId: z.string().min(1).max(255),
authorName: z.string().min(1).max(255),
rating: z.number().int().min(1).max(5),
body: z.string().min(1).max(5000),
postedAt: z.string().datetime(),
});
Every field has both a type and a bound -- not just "is this a string" but "is this a string between 1 and 5000 characters," not just "is this a number" but "is this an integer from 1 to 5." A rating of 47 or a 2MB review body never reaches the insert. The handler validates the JSON parse itself separately from the schema, because malformed JSON and a schema mismatch are different failures worth telling apart:
let json: unknown;
try {
json = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = ingestSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 },
);
}
safeParse rather than parse is the deliberate choice throughout this codebase -- it returns a result object instead of throwing, so a malformed request becomes a clean 400 response instead of an uncaught exception that could 500 the route or leak a stack trace.
The Same Schema, Enforced Twice
The respond-to-review flow validates input in two independent places with the same rule: app/api/reviews/[id]/respond/route.ts (the API route) and app/dashboard/reviews/[id]/actions.ts (the Server Action behind the dashboard form) both define:
const respondSchema = z.object({
body: z.string().min(1, "Response cannot be empty").max(3000),
});
This mirrors the ownership-check duplication from Lesson 22, and for the same reason: a Server Action is its own entry point. The dashboard form having a maxLength attribute in the browser stops nothing -- it is trivial to bypass with a direct POST. The schema check inside the action itself is what actually enforces the bound, regardless of which path a request takes to get there.
Order of Operations Matters
Look again at the ingestion route's sequence: rate limit check first, then JSON parse, then schema validation, then the business-existence lookup, then the plan-limit check (canIngestReview), and only then the insert. Each gate is cheaper than the one after it -- rejecting an over-quota IP before ever touching the database is strictly less work under abuse than validating a full payload first. That ordering is not an accident; it is the same instinct as putting a cheap unit test before an expensive integration test, applied to a request's lifecycle.
tests/rate-limit.test.ts covers the limiter directly -- under-limit requests pass, the exact request that crosses the limit gets blocked, separate keys track independently, and a window reset un-blocks a previously blocked key. None of it touches a live database or a real HTTP request, which is exactly why it runs in milliseconds and exists as a true unit test of the policy itself, not an integration test of a route.
Auth, rate limiting, and validation are the three gates that decide whether a request even reaches business logic. The next lesson is about the kind of bug that gets through all three anyway -- not because any gate was missing, but because of how two separately-correct events can arrive in the wrong order.
- lib/rate-limit.ts is an in-memory fixed-window limiter -- correct for a single instance, but will not coordinate across multiple serverless instances without a shared store like Redis
- Unauthenticated routes (review ingestion) key rate limits by IP; authenticated routes (respond to review) key by user id -- the right key depends on what identity is actually available
- Every route validates with Zod before touching the database, using safeParse so malformed input returns a clean 400 instead of an uncaught exception
- A browser-side maxLength attribute enforces nothing on its own -- the Server Action behind the form repeats the same Zod schema independently, just like the API route does
- Gates are ordered cheapest-to-most-expensive: rate limit, then JSON parse, then schema validation, then ownership checks, then the database write
- 429 is the correct status for rate-limit rejection -- distinct from the 401/403 pair used for authentication and authorization failures