Security Review: APIs and Config
Production security breaches are expensive and embarrassing. A single exposed API key or unvalidated input can compromise your entire application. The most common vulnerabilities come from missing authentication, no input validation, and accidentally exposing secrets to the client.
Outcome
A hardened API route or Server Action with five critical security layers: authentication, input validation, authorization, rate limiting, and error handling.
Fast Track
- Add Zod schema validation (a TypeScript-first library for validating and parsing data) and authentication checks to API routes
- Audit environment variables; remove NEXT_PUBLIC_ from secrets
- Replace stack trace responses with generic error messages
Common Vulnerabilities
Vulnerable API Route Example
// ❌ DANGEROUS: Missing all five security layers
export async function POST(request: Request) {
const { userId, amount } = await request.json()
await db.transactions.create({
userId,
amount,
timestamp: Date.now()
})
return Response.json({ success: true })
}What's wrong:
- No authentication check - anyone can create transactions
- No validation - could accept negative amounts or malicious userId
- No authorization - users could create transactions for other users
- No rate limiting - attackers can spam the endpoint
- No error handling - exposes internal errors and stack traces
Secure API Route Example
import { z } from 'zod'
import { verifyAuth } from '@/lib/auth'
import { checkRateLimit } from '@/lib/rate-limit'
// 1. Input validation schema
const TransactionSchema = z.object({
userId: z.string().uuid(),
amount: z.number().positive().max(10000)
})
export async function POST(request: Request) {
try {
// 2. Authentication check
const user = await verifyAuth(request)
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// 3. Input validation
const body = await request.json()
const { userId, amount } = TransactionSchema.parse(body)
// 4. Authorization check
if (user.id !== userId) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
// 5. Rate limiting
const rateLimited = await checkRateLimit(user.id)
if (rateLimited) {
return Response.json({ error: 'Too many requests' }, { status: 429 })
}
// 6. Safe operation
await db.transactions.create({
userId,
amount,
timestamp: Date.now()
})
return Response.json({ success: true })
} catch (error) {
// 7. Safe error handling
console.error('Transaction error:', error)
return Response.json({ error: 'Server error' }, { status: 500 })
}
}Environment Variable Security
Dangerous Configuration
# ❌ DANGEROUS: Secret exposed to client
DATABASE_URL="postgres://user:pass@localhost:5432/db"
NEXT_PUBLIC_SECRET_API_KEY="sk_live_abc123"
JWT_SECRET="simple123"Why this is dangerous:
NEXT_PUBLIC_SECRET_API_KEYis visible in browser DevTools- Anyone can extract and abuse your API key
JWT_SECRETis too short and predictable
Secure Configuration
# ✅ SECURE: Secrets server-only
DATABASE_URL="postgres://user:pass@localhost:5432/db"
SECRET_API_KEY="sk_live_abc123"
JWT_SECRET="complex-random-32-char-minimum-string-here"
# Only expose non-sensitive data to client
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_xyz789"Safe to expose with NEXT_PUBLIC_:
- ✅ API URLs and endpoints
- ✅ Feature flags and configuration
- ✅ Analytics IDs (Google Analytics, Segment)
- ✅ Publishable keys (Stripe, Google Maps)
Never expose with NEXT_PUBLIC_:
- ❌ Secret API keys
- ❌ Database URLs or credentials
- ❌ JWT secrets
- ❌ Private tokens or passwords
Hands-On Exercise 4.1
The starter repo has an insecure API route. Add security layers to harden it against common vulnerabilities.
Target file: apps/web/src/app/api/transactions/route.ts
Requirements:
- Add Zod schema validation for all inputs
- Add try/catch with safe error handling (no stack traces)
- Implement basic rate limiting (track by IP or user ID)
- Return generic error messages to clients
- Log detailed errors server-side only
Implementation hints:
- Use
zod.safeParse()to catch validation errors gracefully - Return generic error messages like "Invalid input" instead of detailed validation errors
- Add correlation IDs (unique identifiers to trace requests across logs) for debugging:
console.error('[req-123] Transaction failed:', error) - Consider using
server-onlypackage to enforce server-side imports - Test error paths: send invalid JSON, missing auth, wrong user ID
Example validation flow:
const result = TransactionSchema.safeParse(body)
if (!result.success) {
console.error('[req-123] Validation failed:', result.error)
return Response.json({ error: 'Invalid input' }, { status: 400 })
}Try It
-
Test invalid input:
curl -X POST http://localhost:3000/api/transactions \ -H "Content-Type: application/json" \ -d '{"userId": "invalid", "amount": -100}'Expected:
400 Bad Requestwith{ "error": "Invalid input" } -
Test missing authentication:
curl -X POST http://localhost:3000/api/transactions \ -H "Content-Type: application/json" \ -d '{"userId": "user-123", "amount": 100}'Expected:
401 Unauthorizedwith{ "error": "Unauthorized" } -
Verify environment variables:
- Check browser DevTools → Sources → search for
.envvariables - Confirm no secrets appear in client bundle
- Check browser DevTools → Sources → search for
Commit & Deploy
git add -A
git commit -m "feat(polish): harden API with validation, auth, and secure env config"
git push -u origin feat/polish-securityDone-When
curl -X POST localhost:3000/api/transactions -d '{"amount": "bad"}'returns 400 "Invalid input"curl -X POST localhost:3000/api/transactions -d '{"userId": "abc", "amount": -5}'returns 400- 10+ rapid requests return 429 "Too many requests"
- Error responses contain only "Invalid input", "Too many requests", or "Server error" (no stack traces)
- No secrets in browser DevTools Sources tab
Solution
Complete secured API route
import { z } from "zod";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
// 1. Input validation schema
const TransactionSchema = z.object({
userId: z.string().uuid(),
amount: z.number().positive().max(10000),
});
// Simple in-memory rate limiter (use Redis in production)
const requestCounts = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(identifier: string): boolean {
const now = Date.now();
const limit = requestCounts.get(identifier);
if (!limit || now > limit.resetAt) {
requestCounts.set(identifier, { count: 1, resetAt: now + 60000 });
return false;
}
if (limit.count >= 10) {
return true; // Rate limited
}
limit.count++;
return false;
}
export async function POST(request: NextRequest) {
try {
// 2. Rate limiting (by IP for demo)
const ip = request.headers.get("x-forwarded-for") || "unknown";
if (checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}
// 3. Input validation
const body = await request.json();
const result = TransactionSchema.safeParse(body);
if (!result.success) {
console.error("[transaction] Validation failed:", result.error);
return NextResponse.json(
{ error: "Invalid input" },
{ status: 400 }
);
}
const { userId, amount } = result.data;
// 4. Process transaction (mock)
const transaction = {
id: crypto.randomUUID(),
userId,
amount,
timestamp: Date.now(),
};
return NextResponse.json({
success: true,
transactionId: transaction.id,
timestamp: transaction.timestamp,
});
} catch (error) {
// 5. Safe error handling
console.error("[transaction] Error:", error);
return NextResponse.json(
{ error: "Server error" },
{ status: 500 }
);
}
}
export function GET() {
return NextResponse.json({
transactions: [
{ id: "1", amount: 100, status: "completed" },
{ id: "2", amount: 250, status: "pending" },
],
});
}Key security layers implemented:
- Input Validation: Zod schema validates
userIdas UUID andamountas positive number ≤10000 - Rate Limiting: Simple in-memory rate limiter (10 requests/minute per IP)
- Error Handling: Logs detailed errors server-side, returns generic messages to clients
- Safe Parsing: Uses
safeParse()to gracefully handle malformed input
To add authentication, create a helper in src/lib/auth.ts:
import type { NextRequest } from "next/server";
export async function verifyAuth(request: NextRequest) {
// Check for auth header or cookie
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return null;
}
// In production: verify JWT, check session, etc.
// For demo: return mock user
return { id: "user-123", role: "user" };
}5-Layer Security Checklist:
- Authentication: Verify user identity with
verifyAuth() - Input Validation: Use Zod to validate all request data
- Authorization: Check
user.id === requestedUserId - Rate Limiting: Track requests per user/IP and enforce limits
- Error Handling: Log detailed errors server-side, return generic messages
Minimum hardening prevents the most common vulnerabilities that lead to production breaches.
Troubleshooting
References
- https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
- https://nextjs.org/docs/app/building-your-application/routing/route-handlers
- https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
- https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
- https://nextjs.org/docs/app/guides/data-security
- https://zod.dev/ - Zod validation library
- https://nextjs.org/blog/security-nextjs-server-components-actions - Security best practices
Was this helpful?