APIs are easy to generate and easy to leave open. Every gap below produces an API that responds correctly to every request you test — which is exactly why they survive to production.
What You’re Building
A REST API for the projects data from the CRUD tutorial:
GET /api/projects list (paginated)
POST /api/projects create
GET /api/projects/:id read one
PATCH /api/projects/:id update
DELETE /api/projects/:id delete
The First Prompt
Build REST endpoints for projects using Next.js App Router route handlers and TypeScript with Supabase.
Authenticate every request from the session server-side. Derive the user ID from the session only — never accept it from the request body or query.
Validate all input with Zod: title required max 200 chars, description max 2000, status one of active/done/archived, due_date optional ISO date. Reject unknown fields.
Every query filters by both record ID and authenticated user ID. Return proper status codes: 200, 201, 400, 401, 404, 422. Consistent JSON error shape.
The identity clause is the important one. Without it you’ll get something like:
const { userId, title } = await req.json() // WRONG
await supabase.from('projects').insert({ user_id: userId, title })
An endpoint where the caller declares who they are. Anyone can pass any ID.
Pagination — Before You Need It
An unbounded list endpoint returns every row. With 100 records that’s invisible. With 100,000 it takes your database down.
Paginate GET /api/projects with cursor-based pagination. Default limit 25, maximum 100 — clamp anything higher rather than erroring. Return
{ data, nextCursor, hasMore }. Use keyset pagination on created_at and id, not offset.
Offset pagination degrades badly on large tables because the database still walks every skipped row. Keyset stays fast regardless of depth.
Rate Limiting
The gap that turns into a bill.
Add rate limiting with Upstash Ratelimit backed by Redis. 100 requests per minute per authenticated user on read endpoints, 20 per minute on writes. Return 429 with a Retry-After header. Apply it in middleware so no route can be added without it.
Middleware matters — per-route limiting means the next endpoint you add is unprotected by default.
If any endpoint calls a paid API — a language model, email, SMS — it needs a hard per-user quota on top:
Any endpoint calling a paid external API must require authentication and enforce a daily per-user cap stored in the database. Return 402 when exceeded.
Errors That Don’t Leak
Default error handling returns stack traces, file paths, and sometimes connection strings.
In production return generic error messages with a stable error code and no internal detail. Log the full error server-side with a request ID, and include that ID in the response so I can correlate. Never return stack traces or raw database errors.
A response of {"error":"duplicate key value violates unique constraint \"projects_slug_key\""} tells an attacker your schema. {"error":"CONFLICT","requestId":"req_a91f"} doesn’t.
CORS
If only your own frontend calls this API, lock it down. AI defaults to permissive settings because permissive settings never cause errors.
Restrict CORS to my production domain and localhost for development. Do not use a wildcard origin. Only allow the methods and headers actually needed.
Test It Like an Attacker
Five minutes, and it finds most of what’s wrong:
# 1. No auth — should be 401
curl -i localhost:3000/api/projects
# 2. Someone else's record — should be 404
curl -i localhost:3000/api/projects/OTHER_USER_RECORD_ID -H "Cookie: $MY_SESSION"
# 3. Oversized payload — should be 422
curl -i -X POST localhost:3000/api/projects \
-H "Content-Type: application/json" -H "Cookie: $MY_SESSION" \
-d "{\"title\":\"$(python3 -c 'print("x"*10000)')\"}"
# 4. Injected identity — should be ignored
curl -i -X POST localhost:3000/api/projects \
-H "Content-Type: application/json" -H "Cookie: $MY_SESSION" \
-d '{"title":"test","user_id":"SOMEONE_ELSES_ID"}'
# 5. Rate limit — should hit 429
for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code} " \
localhost:3000/api/projects -H "Cookie: $MY_SESSION"; done
Test 4 is the one people are surprised by. If the record gets created under the injected ID, your API lets anyone write data as anyone.
Documenting It
If anyone else will use this:
Generate an OpenAPI 3.1 spec from these route handlers, derived from the Zod schemas so it stays accurate. Serve Scalar docs at /api/docs.
Generating from the schemas rather than hand-writing means the docs can’t drift.
The Checklist
- Identity from session/token only, never from request body
- Zod validation on every input, unknown fields rejected
- Max lengths on all string fields
- Every query filters by authenticated user
- Pagination with a clamped maximum
- Rate limiting applied in middleware
- Hard quotas on any paid-API endpoint
- Generic production errors with correlation IDs
- CORS restricted to known origins
- All five curl tests behave correctly
The Pattern
Every item on that list shares a property: the API works perfectly without it. That’s what makes APIs the most dangerous thing to vibe code without review — there’s no visual output to tell you something’s wrong.
Test it like someone trying to break it, because eventually someone will.
Next: Vibe code an admin panel adds role-based access on top of this, and vibe coding security covers the full picture.
Related reading: