TechLead
Lesson 5 of 22
6 min read
Cybersecurity

API Security

Protect your APIs with authentication, rate limiting, input validation, and defense against common API attack vectors

Why API Security Matters

APIs are the backbone of modern applications. They expose your application's data and functionality to the outside world, making them prime targets for attackers. An insecure API can lead to data breaches, unauthorized access, financial loss, and reputational damage. Unlike traditional web applications where the server controls the rendering, APIs return raw data — meaning any vulnerability is directly exploitable without needing to bypass a UI.

According to security research, API attacks have increased dramatically as organizations adopt microservices architectures and expose more endpoints. The challenge is that APIs are designed to be consumed programmatically, making it easy for attackers to automate their attacks at scale.

API Authentication and Authorization

Every API endpoint must verify both the identity of the caller (authentication) and whether they have permission to perform the requested action (authorization). The most common approaches are API keys, OAuth 2.0 bearer tokens, and mutual TLS.

// Comprehensive API authentication middleware
import { Request, Response, NextFunction } from "express";

interface AuthenticatedRequest extends Request {
  user?: { id: string; role: string; permissions: string[] };
  apiClient?: { id: string; name: string; scopes: string[] };
}

// API Key authentication for service-to-service communication
async function apiKeyAuth(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) {
  const apiKey = req.headers["x-api-key"] as string;

  if (!apiKey) {
    return res.status(401).json({
      error: "API key required",
      code: "MISSING_API_KEY",
    });
  }

  // Hash the API key before looking it up (store hashed keys in DB)
  const hashedKey = crypto
    .createHash("sha256")
    .update(apiKey)
    .digest("hex");

  const client = await db.query(
    "SELECT id, name, scopes, is_active FROM api_clients WHERE key_hash = $1",
    [hashedKey]
  );

  if (!client.rows.length || !client.rows[0].is_active) {
    // Use constant-time comparison to prevent timing attacks
    return res.status(401).json({
      error: "Invalid API key",
      code: "INVALID_API_KEY",
    });
  }

  req.apiClient = client.rows[0];
  next();
}

// Scope-based authorization for API clients
function requireScope(scope: string) {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
    const hasScope = req.apiClient?.scopes?.includes(scope) ||
                     req.user?.permissions?.includes(scope);

    if (!hasScope) {
      return res.status(403).json({
        error: "Insufficient permissions",
        code: "MISSING_SCOPE",
        required: scope,
      });
    }
    next();
  };
}

Input Validation and Sanitization

Every piece of data that enters your API must be validated and sanitized. Never trust input from any source — not from the client, not from other APIs, not even from your own database if it might contain user-generated content. Use a validation library like Zod, Joi, or Yup to define strict schemas for all input.

import { z } from "zod";

// Define strict input schemas with Zod
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[a-zA-Z\s'-]+$/, "Name contains invalid characters"),
  age: z
    .number()
    .int("Age must be a whole number")
    .min(13, "Must be at least 13")
    .max(150, "Invalid age"),
  role: z.enum(["user", "editor"], {
    errorMap: () => ({ message: "Role must be user or editor" }),
  }),
  bio: z
    .string()
    .max(500, "Bio too long")
    .optional()
    .transform((val) => val ? sanitizeHtml(val) : val),
});

// Validation middleware factory
function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(400).json({
        error: "Validation failed",
        code: "VALIDATION_ERROR",
        details: result.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      });
    }

    req.body = result.data; // Use the validated and transformed data
    next();
  };
}

// Query parameter validation
const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["created_at", "updated_at", "name"]).default("created_at"),
  order: z.enum(["asc", "desc"]).default("desc"),
});

app.get("/api/users",
  validateQuery(PaginationSchema),
  async (req, res) => {
    // req.query is now fully validated and typed
    const { page, limit, sort, order } = req.query;
    const offset = (page - 1) * limit;

    const users = await db.query(
      `SELECT id, name, email, created_at FROM users ORDER BY ${sort} ${order} LIMIT $1 OFFSET $2`,
      [limit, offset]
    );

    res.json({ data: users.rows, page, limit, total: users.rowCount });
  }
);

Rate Limiting

Rate limiting is essential for protecting APIs from abuse, preventing brute force attacks, and ensuring fair usage. Implement rate limiting at multiple levels: per IP address, per user/API key, and per endpoint. Use a sliding window algorithm for accuracy and store counters in Redis for distributed systems.

import Redis from "ioredis";

class SlidingWindowRateLimiter {
  private redis: Redis;

  constructor(redis: Redis) {
    this.redis = redis;
  }

  async checkLimit(
    key: string,
    maxRequests: number,
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;
    const member = `${now}:${crypto.randomUUID()}`;

    const pipeline = this.redis.pipeline();
    // Remove expired entries
    pipeline.zremrangebyscore(key, 0, windowStart);
    // Add current request
    pipeline.zadd(key, now, member);
    // Count requests in window
    pipeline.zcard(key);
    // Set expiry on the key
    pipeline.expire(key, windowSeconds);

    const results = await pipeline.exec();
    const requestCount = results![2][1] as number;

    return {
      allowed: requestCount <= maxRequests,
      remaining: Math.max(0, maxRequests - requestCount),
      resetAt: Math.ceil((windowStart + windowSeconds * 1000) / 1000),
    };
  }
}

// Rate limiting middleware with proper headers
function rateLimit(options: { max: number; windowSeconds: number; keyPrefix: string }) {
  const limiter = new SlidingWindowRateLimiter(redis);

  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `${options.keyPrefix}:${req.ip}`;
    const result = await limiter.checkLimit(key, options.max, options.windowSeconds);

    // Always set rate limit headers
    res.setHeader("X-RateLimit-Limit", options.max);
    res.setHeader("X-RateLimit-Remaining", result.remaining);
    res.setHeader("X-RateLimit-Reset", result.resetAt);

    if (!result.allowed) {
      res.setHeader("Retry-After", options.windowSeconds);
      return res.status(429).json({
        error: "Rate limit exceeded",
        code: "RATE_LIMIT_EXCEEDED",
        retryAfter: options.windowSeconds,
      });
    }

    next();
  };
}

// Apply different limits to different endpoints
app.use("/api/auth/login", rateLimit({ max: 5, windowSeconds: 900, keyPrefix: "login" }));
app.use("/api/auth/register", rateLimit({ max: 3, windowSeconds: 3600, keyPrefix: "register" }));
app.use("/api/", rateLimit({ max: 100, windowSeconds: 60, keyPrefix: "api" }));

Security Warning: API Vulnerabilities

  • Broken Object Level Authorization (BOLA): Always verify that the authenticated user has access to the specific resource they are requesting. Don't just check if they are logged in — check if they own the resource.
  • Mass Assignment: Never pass raw request body to database operations. Use allowlists to define which fields users can set.
  • Excessive Data Exposure: Never return entire database objects. Select only the fields the client needs.
  • Missing function-level access control: Check authorization at every endpoint, not just at the router level.

API Error Handling

Proper error handling prevents information leakage. Never expose stack traces, database queries, or internal details in API error responses. Use consistent error formats and generic messages for security-sensitive operations.

// Secure error handling middleware
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public isOperational: boolean = true
  ) {
    super(message);
  }
}

function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  // Log full error details internally
  logger.error({
    error: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    ip: req.ip,
    userId: req.user?.id,
  });

  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
    });
  }

  // For unexpected errors, return generic message
  // NEVER expose internal details to the client
  return res.status(500).json({
    error: "An unexpected error occurred",
    code: "INTERNAL_ERROR",
    // No stack trace, no query details, no file paths
  });
}

API Security Best Practices

  • Always use HTTPS: Never expose APIs over plain HTTP.
  • Validate all input: Use schemas with strict types, ranges, and formats.
  • Implement rate limiting: At multiple levels with proper headers.
  • Return minimal data: Only include fields the client actually needs.
  • Use proper HTTP status codes: 401 for unauthenticated, 403 for unauthorized, 429 for rate limited.
  • Version your APIs: So you can deprecate insecure endpoints without breaking clients.
  • Log everything: Requests, responses, errors, and authentication events for security auditing.

Continue Learning