The API security defaults I wish every tutorial taught
Rate limits, idempotency keys, request signing, structured error envelopes, and input validation. Not advanced security — table stakes. Here's how to implement them in a day.
Security as the default path
Every API I've reviewed that had security issues had one thing in common: security was added after the fact. These are the defaults I bake in from day one.
1. Rate limiting — sliding window, not fixed
Fixed window rate limiting (reset every minute) has a burst vulnerability: an attacker can fire N requests at 11:59:59 and N more at 12:00:01, getting 2N requests in 2 seconds.
Sliding window eliminates this:
async function isRateLimited(key: string, limit: number, windowSecs: number): Promise<boolean> {
const now = Date.now();
const windowStart = now - (windowSecs * 1000);
await redis.zremrangebyscore(key, 0, windowStart);
const count = await redis.zcard(key);
if (count >= limit) return true;
await redis.zadd(key, now, `[now]-[random]`);
await redis.expire(key, windowSecs);
return false;
}
2. Idempotency keys
Payment endpoints and any state-mutating operation should accept an idempotency key:
async function createPayment(req: Request) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) throw new Error('Idempotency-Key header required');
// Check if we've seen this key before
const cached = await redis.get(`idem:[key]`);
if (cached) return JSON.parse(cached); // Return previous response
const result = await processPayment(req.body);
// Cache for 24 hours
await redis.setex(`idem:[key]`, 86400, JSON.stringify(result));
return result;
}
3. Structured error envelopes
Never leak internal errors to clients. Always return a consistent shape:
type ApiError = {
error: {
code: string; // Machine-readable: 'RATE_LIMIT_EXCEEDED'
message: string; // Human-readable: 'Too many requests'
request_id: string; // For support tickets
};
};
The request_id is critical — it lets you correlate client errors with server logs without exposing sensitive internals.
4. Input validation at the boundary
Every endpoint should validate at the HTTP handler level, before business logic:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(254),
name: z.string().min(1).max(100).regex(/^[\p{L}\s'-]+$/u),
password: z.string().min(12).max(128),
});
app.post('/users', async (req, res) => {
const input = CreateUserSchema.safeParse(req.body);
if (!input.success) {
return res.status(400).json({
error: { code: 'VALIDATION_FAILED', message: input.error.issues[0].message, request_id: req.id }
});
}
// input.data is now fully typed and validated
});
5. Security headers
Add these to every response. Most frameworks have middleware:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
These are free. There's no reason not to set them.
Tags
Related Blogs
Beyond JWTs: Designing a Stateful, High-Performance Session Architecture
Stateless JWTs are great until you need to instantly revoke a compromised session. Here's how to build a stateful, Redis-backed authentication system that handles 50k+ concurrent users with sub-millisecond validation.
Defending Against SSRF in Node.js Microservices
Server-Side Request Forgery is deadly. If your app fetches URLs provided by users, you are at risk. Here's how to lock down node-fetch and axios.
Building Reliable Audit Logging Systems in Postgres
Why application-level audit logging fails, and how to use Postgres triggers for immutable audit trails.