api-route-hardening
Skill26 starsChanged 6 months agoMITView on GitHub
Use when the user creates a route handler, Server Action, RPC endpoint, or any HTTP endpoint that mutates state or accepts user input. Covers rate limiting, body-size caps, request timeouts, idempotency keys, error response contracts, and method/CORS hygiene.
In this repository19 other files it gives its agents.
- auth-and-sessionsSkill
- authorizationSkill
- caching-and-invalidationSkill
- database-query-safetySkill
- data-privacySkill
- error-handlingSkill
- file-uploadsSkill
- input-validationSkill
- jobs-and-queuesSkill
- llm-endpoint-safetySkill
- migrations-and-schemaSkill
- observabilitySkill
- production-readinessSkill
- reliability-patternsSkill
- rsc-boundariesSkill
- secrets-and-envSkill
- testing-strategySkill
- transactions-and-consistencySkill
- webhooksSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 22
Every endpoint that mutates or costs money goes through this in order:
- Distinctive: line 38
Use Upstash Ratelimit, Cloudflare Rate Limiting, or your platform's built-in.
- Very specific: line 150
Always cap limit (e.g., max 100).
- Very specific: line 173
When a user reports a bug, they paste the request ID and you find every log line for that request in seconds.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
API Route Hardening
Every endpoint is a tiny program that strangers can run as many times as they want. Treat it that way.
When to use
- Creating a new
route.ts, Server Action, or HTTP endpoint - Adding a public-facing API
- Reviewing existing endpoints before launch
- Anywhere a
POST/PUT/PATCH/DELETEexists without rate limits
When to skip
- Static page rendering with no endpoint
- Internal-only endpoints behind a private network with mTLS or VPC enforcement (rare)
The hardening stack
Every endpoint that mutates or costs money goes through this in order:
- Method check → reject anything other than the intended verb
- Body size limit → reject before parsing
- Authentication → who is the caller
- Authorization → may they do this (see
authorization) - Rate limit → per-IP and/or per-user, before expensive work
- Input validation → Zod parse (see
input-validation) - Idempotency check (mutations) → has this exact request already been processed?
- The actual work
- Structured response → consistent error/success contract
The rules
1. Rate limit every mutating endpoint. And every expensive read.
Use Upstash Ratelimit, Cloudflare Rate Limiting, or your platform's built-in. Sliding window for general endpoints, token bucket for bursty workloads.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, '60 s'), // 20 req / min / key
analytics: true,
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'
const { success } = await limiter.limit(`signup:${ip}`)
if (!success) return new Response('Too many requests', { status: 429 })
// ...
}Limit by the most specific identifier you have: user ID > session ID > IP. Per-route, not global. Stricter limits on auth/reset/invite endpoints (see auth-and-sessions).
2. Cap request body size. Before parsing.
The default in many frameworks is 1MB or larger. For a POST /api/notes you probably want 100KB.
// Next.js Route Handler
const MAX = 100 * 1024
const len = Number(req.headers.get('content-length') ?? 0)
if (len > MAX) return new Response('Payload too large', { status: 413 })For uploads, use signed URLs (see file-uploads) — never proxy the bytes through your server.
3. Set a request timeout. The platform default is too long.
A handler that hangs on a slow third-party call holds a connection slot until something kills it. Set per-request timeouts; use AbortSignal.timeout(ms) for outbound calls (see reliability-patterns).
4. Idempotency keys on every mutating endpoint that costs money or sends a side effect.
// Client sends Idempotency-Key: <uuid>
const key = req.headers.get('Idempotency-Key')
if (!key) return new Response('Missing Idempotency-Key', { status: 400 })
const existing = await db.idempotencyKey.findUnique({ where: { key } })
if (existing) return Response.json(existing.response, { status: existing.status })
// ... do the work ...
await db.idempotencyKey.create({
data: { key, status: 200, response: result, expiresAt: addDays(new Date(), 1) },
})Why: client retries (network blip, user double-click, queue redelivery) must not double-charge, double-send, or double-create. Stripe-style — they made this the norm because they had to.
5. Reject the wrong method. Explicitly.
// Next.js: only export the verbs you support; others auto-405. Good.
// Express: add an explicit `app.all('/path', methodNotAllowed)`.A GET to a POST-only endpoint should never reach business logic. Same with OPTIONS preflights — let the framework handle CORS, don't hand-roll.
6. Stable error contract. JSON, not strings.
// BAD
return new Response('something went wrong', { status: 500 })
// GOOD
return Response.json(
{ error: { code: 'invoice_not_found', message: 'No invoice with that ID.' } },
{ status: 404 },
)Codes are stable identifiers your client can branch on. Messages are human-readable but never include internals (stack traces, SQL, secrets — see secrets-and-env).
7. CORS: allowlist, not *.
// BAD
'Access-Control-Allow-Origin': '*'
'Access-Control-Allow-Credentials': 'true' // browsers actually reject this combo, but people try
// GOOD — allowlist of known origins, or omit if same-origin
const ALLOWED = new Set(['https://app.example.com', 'https://example.com'])
const origin = req.headers.get('origin')
if (origin && ALLOWED.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin)
headers.set('Vary', 'Origin')
}8. Pagination: cursor-based, not offset-based, for anything that grows.
// BAD — offset gets slower as N grows, drifts when rows are inserted
db.post.findMany({ skip: 1000, take: 20 })
// GOOD — cursor is stable and fast
db.post.findMany({
take: 20,
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { id: 'desc' },
})Always cap limit (e.g., max 100). See database-query-safety.
9. Don't return raw DB rows.
Map DB → DTO at the response boundary. Otherwise adding a field to the schema (internalNotes, passwordHash, stripeCustomerId) silently leaks it to clients.
return Response.json({
id: user.id,
name: user.name,
email: user.email,
// not: ...user
})10. Add a request_id to every response and every log.
const requestId = crypto.randomUUID()
logger.info({ requestId, route: '/api/x', userId })
return Response.json(data, { headers: { 'x-request-id': requestId } })When a user reports a bug, they paste the request ID and you find every log line for that request in seconds. See observability.
War story
An indie SaaS shipped POST /api/contact with no rate limit. A scraper found it, sent 80k requests in two hours through their Resend integration. $400 in transactional email costs, IP reputation tanked, deliverability dropped for two weeks across all customers. A 5-line Upstash limiter would have stopped it at request 21.
Quick checklist
- Rate limit per-IP and per-user, scoped to the route
- Body size cap before parsing
- Auth + authz before any work
- Zod-parsed input (see
input-validation) - Idempotency key for charges, emails, and other irreversible side effects
- Stable JSON error contract with codes
- CORS allowlist, not
* - Cursor pagination with a max limit
- Response DTOs, not raw DB rows
x-request-idechoed on every response and in every log line
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

