TechLead
Lesson 15 of 22
5 min read
Cybersecurity

CORS Deep Dive

Understand Cross-Origin Resource Sharing, preflight requests, credentials, and how to configure CORS securely

Understanding CORS

Cross-Origin Resource Sharing (CORS) is a browser security mechanism that restricts how resources on a web page can be requested from another domain. By default, browsers enforce the Same-Origin Policy, which prevents JavaScript on one origin from making requests to a different origin. CORS is the controlled mechanism for relaxing this restriction when needed.

An origin is defined by the combination of protocol, hostname, and port. So https://app.example.com and https://api.example.com are different origins, even though they share the same parent domain. http://example.com and https://example.com are also different origins because the protocol differs.

How CORS Works

  1. Simple requests: For GET, HEAD, or POST requests with simple headers, the browser sends the request directly and checks the Access-Control-Allow-Origin header in the response.
  2. Preflight requests: For requests with custom headers, non-simple methods (PUT, DELETE), or Content-Type other than form-data/text/plain, the browser first sends an OPTIONS preflight request to ask the server what is allowed.
  3. Credentialed requests: Requests that include cookies or authorization headers require the server to explicitly allow credentials with Access-Control-Allow-Credentials: true.

Implementing CORS Securely

import { Request, Response, NextFunction } from "express";

// Secure CORS configuration
const ALLOWED_ORIGINS = new Set([
  "https://app.mycompany.com",
  "https://admin.mycompany.com",
  "https://staging.mycompany.com",
]);

// In development, also allow localhost
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}

function corsMiddleware(req: Request, res: Response, next: NextFunction) {
  const origin = req.headers.origin;

  // Only set CORS headers if origin is in the allowlist
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    // Reflect the allowed origin (not wildcard when using credentials)
    res.setHeader("Access-Control-Allow-Origin", origin);

    // Allow cookies and authorization headers
    res.setHeader("Access-Control-Allow-Credentials", "true");

    // Allowed HTTP methods
    res.setHeader(
      "Access-Control-Allow-Methods",
      "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    );

    // Allowed request headers
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization, X-Request-ID"
    );

    // How long the preflight response can be cached (in seconds)
    res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours

    // Which response headers the client can access
    res.setHeader(
      "Access-Control-Expose-Headers",
      "X-Request-ID, X-RateLimit-Remaining"
    );

    // IMPORTANT: Vary by Origin so CDNs cache correctly
    res.setHeader("Vary", "Origin");
  }

  // Handle preflight requests
  if (req.method === "OPTIONS") {
    return res.status(204).end();
  }

  next();
}

app.use(corsMiddleware);

Security Warning: CORS Mistakes

  • Never use Access-Control-Allow-Origin: * with credentials: Browsers reject this combination, but even without credentials, wildcard CORS exposes your API to any website.
  • Never reflect the Origin header blindly: Setting Access-Control-Allow-Origin to whatever origin the request sends is equivalent to a wildcard and defeats CORS entirely.
  • Validate origin strictly: Do not use regex or substring matching that could be bypassed (e.g., evil-mycompany.com matching mycompany.com).
  • Do not over-expose headers: Only expose response headers that the client actually needs via Access-Control-Expose-Headers.

CORS for Different Architectures

// Pattern 1: API gateway handles CORS for all microservices
// This centralizes CORS configuration and avoids per-service config

// Pattern 2: Per-route CORS (different policies for different endpoints)
function routeSpecificCors(allowedOrigins: string[]) {
  const originSet = new Set(allowedOrigins);

  return (req: Request, res: Response, next: NextFunction) => {
    const origin = req.headers.origin;
    if (origin && originSet.has(origin)) {
      res.setHeader("Access-Control-Allow-Origin", origin);
      res.setHeader("Vary", "Origin");
    }

    if (req.method === "OPTIONS") {
      res.setHeader("Access-Control-Allow-Methods", "GET, POST");
      res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
      res.setHeader("Access-Control-Max-Age", "86400");
      return res.status(204).end();
    }

    next();
  };
}

// Public API: wider access
app.use("/api/public", routeSpecificCors([
  "https://app.mycompany.com",
  "https://partner1.com",
  "https://partner2.com",
]));

// Admin API: restricted access
app.use("/api/admin", routeSpecificCors([
  "https://admin.mycompany.com",
]));

// Pattern 3: Proxy approach (avoid CORS entirely)
// In Next.js, use API routes as a proxy
// /app/api/external/route.ts
export async function GET(request: Request) {
  // This runs server-side, so no CORS restrictions
  const data = await fetch("https://external-api.com/data", {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
  });

  return Response.json(await data.json());
  // Client calls /api/external instead of external-api.com directly
  // No CORS headers needed since it is same-origin
}

CORS Best Practices

  • Use explicit origin allowlists: Never use wildcards or reflect origins blindly.
  • Cache preflight responses: Set Access-Control-Max-Age to reduce preflight requests.
  • Use a proxy for third-party APIs: Make API calls server-side to avoid CORS issues entirely.
  • Set the Vary header: Always include Vary: Origin when the CORS response depends on the origin.
  • Minimize exposed headers: Only expose what the client needs in Access-Control-Expose-Headers.
  • Test preflight behavior: Verify that OPTIONS requests are handled correctly and cached properly.

Continue Learning