Security Fundamentals
Securing APIs and Endpoints
APIs are attacked programmatically at scale — learn the complete set of protections every API needs.
API Security Is Different
APIs are different from web pages in one critical way: they are attacked programmatically, at scale, without a human in the loop. While a web page might be probed by a human a few times per minute, an API endpoint might receive thousands of automated attack requests per second.
Every API endpoint is a potential attack surface. Treat each one with the same discipline you'd apply to a bank vault.
API Key Management
For service-to-service communication, use API keys:
// Generate a cryptographically secure API key
import { randomBytes } from 'crypto';
const apiKey = randomBytes(32).toString('hex');
// Store the HASH of the key (never the key itself)
import { createHash } from 'crypto';
const keyHash = createHash('sha256').update(apiKey).digest('hex');
await db.apiKeys.create({ hash: keyHash, userId, scopes: ['read'] });
// Validate incoming keys
export async function validateApiKey(providedKey: string) {
const hash = createHash('sha256').update(providedKey).digest('hex');
return db.apiKeys.findByHash(hash);
}Key principles:
- Pass keys in the Authorization header (never in URLs — URLs appear in logs)
- Store only the hash (like passwords)
- Scope keys to minimum required permissions
- Rotate keys regularly
Rate Limiting
Rate limiting prevents brute force attacks and API abuse:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});
export async function middleware(req: NextRequest) {
const ip = req.ip ?? '127.0.0.1';
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'Retry-After': '60',
},
});
}
}Response Security
Never leak internal details in API error responses:
// DANGEROUS — leaks stack trace and internal details
catch (error) {
return Response.json({ error: error.message, stack: error.stack }, { status: 500 });
}
// SECURE — generic message to client, full details logged internally
catch (error) {
console.error('API error:', { error, userId, requestId });
return Response.json(
{ error: 'An unexpected error occurred', requestId },
{ status: 500 }
);
}Webhook Security
Webhooks from external services (Stripe, GitHub) must be verified:
import Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// Now safe to process the event
}The signature verification ensures the request genuinely came from Stripe and was not tampered with.
Key Takeaways
- APIs are attacked programmatically — rate limiting is not optional
- API keys belong in the Authorization header, not in URLs
- Store only the hash of API keys — never the keys themselves
- Error responses should be generic to the client but detailed in your logs
- Verify webhook signatures using HMAC to ensure requests genuinely came from the expected source
Example
// API route with full security: auth + rate limiting + validation
import { z } from 'zod';
const CreateSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().max(10000),
});
export async function POST(req: Request) {
// 1. Authentication
const token = req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const user = await verifyToken(token);
if (!user) return Response.json({ error: 'Invalid token' }, { status: 401 });
// 2. Rate limiting (checked in middleware)
// 3. Input validation
const body = await req.json();
const result = CreateSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
// 4. Business logic with parameterized queries
const post = await db.posts.create({ ...result.data, userId: user.id });
// 5. Safe response (no internal details)
return Response.json({ id: post.id, title: post.title }, { status: 201 });
}