Skip to content

API Defense in Depth: Rate Limits, Validation, Headers

API security layers beyond auth: rate limiting, request validation, output encoding, security headers and audit logging, with TypeScript middleware.

5 min read
Layered security architecture diagram showing requests passing through rate limiting, authentication, validation, authorization, and audit logging layers

Authentication verifies identity. It answers "who are you?" But a properly authenticated user can still abuse your API—sending malformed data, hammering endpoints, exploiting business logic, or extracting data through unbounded queries. The layers beyond authentication determine whether your API survives contact with real-world traffic.

Defense in depth means each layer assumes the previous one might fail. Rate limiting doesn't assume the firewall blocked the attack. Input validation doesn't assume the client sent well-formed data. Output encoding doesn't assume the database contained clean values.

Rate Limiting: Protecting Capacity

Rate limiting prevents any single client from consuming disproportionate resources. Without it, a single aggressive client—malicious or buggy—can deny service to everyone else.

tstypescript
// ❌ No rate limiting — any client can overwhelm the API
app.post("/api/search", async (req, res) => {
  const results = await db.fullTextSearch(req.body.query);
  res.json(results);
  // An attacker sends 10,000 requests/second
  // Database overwhelmed, other users get timeouts
});
tstypescript
// ✅ Layered rate limiting middleware
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
  keyPrefix: string;
}
 
async function checkRateLimit(
  key: string,
  config: RateLimitConfig
): Promise<{
  allowed: boolean;
  remaining: number;
  resetAt: number;
}> {
  const windowKey =
    `${config.keyPrefix}:${key}:` +
    `${Math.floor(Date.now() / config.windowMs)}`;
 
  const count = await redis.incr(windowKey);
 
  if (count === 1) {
    await redis.pexpire(windowKey, config.windowMs);
  }
 
  const remaining = Math.max(
    0,
    config.maxRequests - count
  );
  const resetAt =
    Math.ceil(Date.now() / config.windowMs) *
    config.windowMs;
 
  return {
    allowed: count <= config.maxRequests,
    remaining,
    resetAt,
  };
}
 
function rateLimiter(config: RateLimitConfig) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    // Use authenticated user ID, fall back to IP
    const key =
      req.user?.id ?? req.ip ?? "unknown";
 
    const result = await checkRateLimit(key, config);
 
    // Always set rate limit headers
    res.set({
      "X-RateLimit-Limit": String(config.maxRequests),
      "X-RateLimit-Remaining": String(result.remaining),
      "X-RateLimit-Reset": String(result.resetAt),
    });
 
    if (!result.allowed) {
      res.status(429).json({
        error: "Too many requests",
        retryAfter: Math.ceil(
          (result.resetAt - Date.now()) / 1000
        ),
      });
      return;
    }
 
    next();
  };
}
 
// Different limits for different endpoints
app.use(
  "/api/search",
  rateLimiter({
    windowMs: 60_000,
    maxRequests: 30,
    keyPrefix: "rl:search",
  })
);
 
app.use(
  "/api/auth/login",
  rateLimiter({
    windowMs: 900_000, // 15 minutes
    maxRequests: 5,     // Strict for auth
    keyPrefix: "rl:login",
  })
);

Input Validation: Rejecting Bad Data Early

Every request parameter is untrusted input. Validate shape, type, and constraints before processing.

tstypescript
import { z } from "zod";
 
// ❌ Trusting client input
app.post("/api/users", async (req, res) => {
  // req.body could be anything — no validation
  await db.query(
    "INSERT INTO users (name, email, role) VALUES ($1, $2, $3)",
    [req.body.name, req.body.email, req.body.role]
    // Attacker sets role: "admin" — privilege escalation
  );
});
tstypescript
// ✅ Strict schema validation with Zod
const createUserSchema = z.object({
  name: z
    .string()
    .min(1)
    .max(100)
    .regex(
      /^[\p{L}\p{N}\s\-'.]+$/u,
      "Invalid characters in name"
    ),
  email: z.string().email().max(254),
  // Role is NOT user-settable — determined by backend
});
 
const searchSchema = z.object({
  query: z
    .string()
    .min(1)
    .max(200)
    .transform((q) => q.trim()),
  page: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(1),
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(50)
    .default(20),
  // Prevent unbounded queries
});
 
function validate<T>(schema: z.ZodSchema<T>) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const result = schema.safeParse(req.body);
 
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      });
      return;
    }
 
    req.body = result.data;
    next();
  };
}
 
app.post(
  "/api/users",
  validate(createUserSchema),
  async (req, res) => {
    // req.body is now typed and validated
    const { name, email } = req.body;
    // Role assigned by backend logic, never from input
    const role = "user";
    await db.createUser({ name, email, role });
    res.status(201).json({ name, email, role });
  }
);

Security Headers: Hardening Responses

Security headers instruct browsers how to handle your responses. Missing headers leave clients vulnerable to clickjacking, XSS, and protocol downgrade attacks.

tstypescript
function securityHeaders(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Prevent clickjacking
  res.set("X-Frame-Options", "DENY");
 
  // Block MIME-type sniffing
  res.set("X-Content-Type-Options", "nosniff");
 
  // Enable strict transport security
  res.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
 
  // Content Security Policy for APIs
  res.set(
    "Content-Security-Policy",
    "default-src 'none'; frame-ancestors 'none'"
  );
 
  // Control referrer information
  res.set("Referrer-Policy", "strict-origin");
 
  // Permissions policy
  res.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=()"
  );
 
  next();
}
 
app.use(securityHeaders);

Output Encoding: Sanitizing Responses

Data stored in your database may contain malicious content. Encode output before sending it to clients.

tstypescript
// ❌ Raw database values in response
app.get("/api/comments/:postId", async (req, res) => {
  const comments = await db.getComments(req.params.postId);
  res.json(comments);
  // If a comment contains <script>alert('xss')</script>
  // and the client renders it as HTML — XSS
});
tstypescript
// ✅ Sanitize output
import DOMPurify from "isomorphic-dompurify";
 
function sanitizeOutput<T extends Record<string, unknown>>(
  obj: T,
  htmlFields: string[] = []
): T {
  const sanitized = { ...obj };
 
  for (const [key, value] of Object.entries(sanitized)) {
    if (typeof value === "string") {
      if (htmlFields.includes(key)) {
        // Allow safe HTML in designated fields
        (sanitized as Record<string, unknown>)[key] =
          DOMPurify.sanitize(value, {
            ALLOWED_TAGS: [
              "b",
              "i",
              "em",
              "strong",
              "a",
              "p",
              "br",
            ],
            ALLOWED_ATTR: ["href"],
          });
      } else {
        // Strip all HTML from non-HTML fields
        (sanitized as Record<string, unknown>)[key] =
          value
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
      }
    }
  }
 
  return sanitized;
}
 
app.get(
  "/api/comments/:postId",
  async (req, res) => {
    const comments = await db.getComments(
      req.params.postId
    );
    const safe = comments.map((c) =>
      sanitizeOutput(c, ["body"])
    );
    res.json(safe);
  }
);

Audit Logging: Recording What Happened

When a security incident occurs, audit logs tell you what happened, when, and who was involved. Without them, incident response is guesswork.

tstypescript
interface AuditEvent {
  timestamp: string;
  userId: string | null;
  action: string;
  resource: string;
  resourceId: string;
  ip: string;
  userAgent: string;
  outcome: "success" | "failure" | "denied";
  details?: Record<string, unknown>;
}
 
class AuditLogger {
  async log(event: AuditEvent): Promise<void> {
    // Write to append-only audit store
    await db.query(
      `INSERT INTO audit_log
       (timestamp, user_id, action, resource,
        resource_id, ip, user_agent, outcome, details)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
      [
        event.timestamp,
        event.userId,
        event.action,
        event.resource,
        event.resourceId,
        event.ip,
        event.userAgent,
        event.outcome,
        JSON.stringify(event.details ?? {}),
      ]
    );
  }
}
 
const audit = new AuditLogger();
 
// Audit middleware for sensitive operations
function auditAction(action: string, resource: string) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const originalJson = res.json.bind(res);
    const startTime = new Date().toISOString();
 
    res.json = function (body: unknown) {
      const outcome =
        res.statusCode >= 400 ? "failure" : "success";
 
      audit.log({
        timestamp: startTime,
        userId: req.user?.id ?? null,
        action,
        resource,
        resourceId:
          req.params.id ?? "unknown",
        ip: req.ip ?? "unknown",
        userAgent:
          req.headers["user-agent"] ?? "unknown",
        outcome,
      });
 
      return originalJson(body);
    };
 
    next();
  };
}
 
app.delete(
  "/api/users/:id",
  auditAction("delete", "user"),
  async (req, res) => {
    await db.deleteUser(req.params.id);
    res.json({ deleted: true });
    // Audit log automatically captures: who deleted
    // which user, when, from what IP
  }
);

Key Takeaways

Rate limiting must be layered and endpoint-specific—authentication endpoints need strict limits (5 attempts per 15 minutes), search endpoints need moderate limits, and read endpoints can be more permissive—using authenticated user ID as the primary key with IP fallback for unauthenticated requests. Input validation with schema libraries like Zod should reject invalid requests at the boundary before any business logic executes, enforcing type constraints, length limits, and explicit allowlists for enumerated values while never trusting client-provided role or permission fields. Security headers form a passive defense layer that costs nothing to implement—Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy, and X-Frame-Options prevent entire categories of attacks regardless of application logic correctness. Audit logging on sensitive operations creates an investigation trail that turns incident response from guesswork into evidence-based analysis—log who performed what action on which resource with what outcome, and store these records in an append-only store separate from application data.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX