/ blog/api-security-defaults
blog / api-security-defaults / overview.md

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

apisecuritybackend
0
0