MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Understanding CORS: A Developer's Guide

Master Cross-Origin Resource Sharing: preflight requests, headers, and common pitfalls.

CORSWeb SecurityHTTPBackend

By MinhVo

Introduction

Cross-Origin Resource Sharing (CORS) is one of the most misunderstood yet critical security mechanisms in web development. Nearly every developer has encountered a CORS error at some point — those cryptic browser console messages that seem to block perfectly valid requests. Understanding how CORS actually works beneath the surface is essential for building modern web applications that safely communicate across domains.

In this guide, we'll demystify CORS completely. You'll learn why it exists, how browsers enforce it, what preflight requests are, and how to configure your server correctly. By the end, you'll never be confused by a CORS error again, and you'll know exactly which headers to set and when.

Web Security and CORS

Understanding CORS: Core Concepts

CORS is a browser security mechanism that restricts web pages from making requests to a different origin than the one that served the web page. An "origin" is defined by the combination of protocol (http/https), domain, and port. For example, https://app.example.com:443 is a different origin from http://api.example.com:8080.

The same-origin policy (SOP) is the foundation that CORS builds upon. Without CORS, a web application loaded from https://myapp.com could not make API calls to https://api.myapp.com — even though they share the same domain. CORS provides a controlled way to relax this restriction, allowing servers to explicitly declare which origins are permitted to access their resources.

The Same-Origin Policy

The same-origin policy was introduced in Netscape Navigator 2.0 in 1995 to prevent malicious scripts on one page from accessing data on another page through the Document Object Model. Two URLs have the same origin if their protocol, port, and host are all the same.

// Same origin - all three parts match
"https://example.com/page1" → "https://example.com/page2"
 
// Different origins - at least one part differs
"https://example.com" → "http://example.com"       // protocol differs
"https://example.com" → "https://example.com:8080"  // port differs
"https://example.com" → "https://api.example.com"   // host differs
"https://example.com" → "https://other.com"         // host differs

The same-origin policy applies to several web platform APIs, including XMLHttpRequest, fetch, web fonts, WebGL textures, and stylesheets loaded via <link>. However, it does not block all cross-origin requests — certain tags like <script>, <img>, <video>, and <link> are exempt from the same-origin policy by design.

Simple Requests vs Preflight Requests

CORS distinguishes between "simple" requests and requests that require a preflight. A simple request meets all of these criteria: the method is GET, HEAD, or POST; the headers are limited to Accept, Accept-Language, Content-Language, and Content-Type (with specific values); and the Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Any request that doesn't meet these criteria triggers a preflight — the browser sends an OPTIONS request first to check if the actual request is allowed. This is why you often see two requests in your network tab for a single API call.

// Simple request - no preflight needed
fetch("https://api.example.com/data", {
  method: "GET",
  headers: { "Accept": "application/json" }
});
 
// Triggers preflight - custom header is not a "simple" header
fetch("https://api.example.com/data", {
  method: "GET",
  headers: {
    "Authorization": "Bearer token123",
    "X-Custom-Header": "value"
  }
});
 
// Triggers preflight - PUT is not a simple method
fetch("https://api.example.com/data/1", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Updated" })
});

How Preflight Requests Work

When the browser determines that a request is not simple, it first sends an OPTIONS request to the server with headers that describe the intended actual request. The server responds with headers indicating whether the actual request is permitted.

// Browser sends this OPTIONS request automatically:
// OPTIONS /api/data HTTP/1.1
// Host: api.example.com
// Origin: https://myapp.com
// Access-Control-Request-Method: PUT
// Access-Control-Request-Headers: Content-Type, Authorization
 
// Server responds:
// HTTP/1.1 204 No Content
// Access-Control-Allow-Origin: https://myapp.com
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE
// Access-Control-Allow-Headers: Content-Type, Authorization
// Access-Control-Max-Age: 86400
 
// If the preflight response allows it, the browser then sends the actual PUT request

The Access-Control-Max-Age header tells the browser how long (in seconds) to cache the preflight response, avoiding repeated OPTIONS requests for the same endpoint.

HTTP Headers and CORS

Architecture and CORS Headers

Understanding the complete set of CORS headers and how they interact is essential for proper configuration. Each header serves a specific purpose and has specific valid values.

Access-Control-Allow-Origin

This is the most fundamental CORS header. It tells the browser which origin is allowed to access the resource. The value can be either * (any origin) or a specific origin URL.

// Allow any origin (simple but less secure)
res.setHeader("Access-Control-Allow-Origin", "*");
 
// Allow a specific origin (more secure)
res.setHeader("Access-Control-Allow-Origin", "https://myapp.com");
 
// Dynamic origin validation
const allowedOrigins = [
  "https://myapp.com",
  "https://admin.myapp.com",
  "http://localhost:3000"
];
 
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
  res.setHeader("Access-Control-Allow-Origin", origin);
  res.setHeader("Vary", "Origin"); // Important for caching
}

Note that Access-Control-Allow-Origin: * cannot be used when credentials are included. The browser will reject the response if withCredentials is true and the allowed origin is *.

Access-Control-Allow-Credentials

This header indicates whether the response to the request can be exposed when the credentials flag is true. Credentials include cookies, HTTP authentication, and client-side TLS certificates.

// Allow credentials (cookies, auth headers)
res.setHeader("Access-Control-Allow-Credentials", "true");
 
// Client-side: include credentials
fetch("https://api.example.com/data", {
  credentials: "include", // Send cookies with cross-origin requests
  headers: {
    "Authorization": "Bearer token123"
  }
});
 
// credentials options:
// "omit"     - Never send credentials
// "same-origin" - Only send credentials for same-origin requests
// "include"  - Always send credentials (even cross-origin)

Access-Control-Expose-Headers

By default, the browser only exposes a subset of response headers to JavaScript in cross-origin requests. This header lets you specify additional headers that should be accessible.

// Expose custom headers to the client
res.setHeader(
  "Access-Control-Expose-Headers",
  "X-Request-Id, X-Total-Count, X-Rate-Limit-Remaining"
);
 
// Client-side: access exposed headers
const response = await fetch("https://api.example.com/data");
const requestId = response.headers.get("X-Request-Id");
const totalCount = response.headers.get("X-Total-Count");

Access-Control-Allow-Methods

This header specifies the HTTP methods allowed when accessing the resource. It's used in preflight responses to indicate which methods the client is allowed to use.

// Allow specific methods
res.setHeader(
  "Access-Control-Allow-Methods",
  "GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
 
// Allow all common methods
res.setHeader("Access-Control-Allow-Methods", "*");

Access-Control-Allow-Headers

This header is used in response to a preflight request to indicate which HTTP headers can be used during the actual request.

// Allow specific headers
res.setHeader(
  "Access-Control-Allow-Headers",
  "Content-Type, Authorization, X-Requested-With, X-Api-Key"
);
 
// Allow all requested headers
res.setHeader("Access-Control-Allow-Headers", "*");

Step-by-Step Implementation

Express.js CORS Configuration

The most common approach for Node.js applications is using the cors middleware package:

import express from "express";
import cors from "cors";
 
const app = express();
 
// Basic CORS - allow all origins
app.use(cors());
 
// CORS with specific options
app.use(cors({
  origin: "https://myapp.com",
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["X-Request-Id", "X-Total-Count"],
  credentials: true,
  maxAge: 86400 // 24 hours
}));
 
// Dynamic origin validation
const allowedOrigins = [
  "https://myapp.com",
  "https://admin.myapp.com",
  "http://localhost:3000"
];
 
app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed by CORS"));
    }
  },
  credentials: true,
}));

Custom CORS Middleware

For more control, you can write custom CORS middleware:

import { Request, Response, NextFunction } from "express";
 
const allowedOrigins = new Set([
  "https://myapp.com",
  "https://admin.myapp.com",
  "http://localhost:3000",
]);
 
function corsMiddleware(req: Request, res: Response, next: NextFunction) {
  const origin = req.headers.origin;
 
  // Set allowed origin
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
 
  // Allow credentials
  res.setHeader("Access-Control-Allow-Credentials", "true");
 
  // Handle preflight
  if (req.method === "OPTIONS") {
    res.setHeader(
      "Access-Control-Allow-Methods",
      "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    );
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization, X-Requested-With"
    );
    res.setHeader("Access-Control-Max-Age", "86400");
    return res.status(204).send();
  }
 
  next();
}
 
app.use(corsMiddleware);

Nginx CORS Configuration

For applications behind an Nginx reverse proxy:

server {
    listen 443 ssl;
    server_name api.example.com;
 
    location /api/ {
        # Check origin
        set $cors_origin "";
        if ($http_origin = "https://myapp.com") {
            set $cors_origin $http_origin;
        }
        if ($http_origin = "https://admin.myapp.com") {
            set $cors_origin $http_origin;
        }
 
        # Set CORS headers
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
        add_header Access-Control-Max-Age 86400 always;
 
        # Handle preflight
        if ($request_method = OPTIONS) {
            return 204;
        }
 
        proxy_pass http://backend;
    }
}

AWS Lambda CORS Configuration

// AWS Lambda with API Gateway
export const handler = async (event: APIGatewayProxyEvent) => {
  const headers = {
    "Access-Control-Allow-Origin": "https://myapp.com",
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type,Authorization",
  };
 
  // Handle preflight
  if (event.httpMethod === "OPTIONS") {
    return {
      statusCode: 204,
      headers,
      body: "",
    };
  }
 
  // Actual request handling
  return {
    statusCode: 200,
    headers,
    body: JSON.stringify({ message: "Success" }),
  };
};

Server Configuration

Real-World Use Cases

Use Case 1: Multi-Domain Architecture

Many modern applications use separate domains for the frontend and API. For example, the frontend is served from https://app.example.com while the API runs on https://api.example.com. This requires proper CORS configuration to allow the frontend to communicate with the API.

// API server CORS configuration for multi-domain setup
const corsOptions = {
  origin: (origin: string | undefined, callback: Function) => {
    const allowedOrigins = [
      "https://app.example.com",
      "https://admin.example.com",
      "https://www.example.com",
    ];
 
    // Allow requests with no origin (mobile apps, curl, etc.)
    if (!origin) return callback(null, true);
 
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error("CORS policy violation"));
    }
  },
  credentials: true,
  maxAge: 600, // Cache preflight for 10 minutes
};

Use Case 2: Third-Party API Integration

When your frontend needs to call a third-party API that doesn't support CORS, you'll need a proxy server to relay the requests.

// Proxy server for third-party API
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
 
const app = express();
 
// Add CORS headers for your own frontend
app.use(cors({ origin: "https://myapp.com", credentials: true }));
 
// Proxy third-party API requests
app.use("/api/third-party", createProxyMiddleware({
  target: "https://third-party-api.com",
  changeOrigin: true,
  pathRewrite: { "^/api/third-party": "" },
  onProxyRes: (proxyRes) => {
    // Remove third-party CORS headers
    delete proxyRes.headers["access-control-allow-origin"];
  },
}));

Use Case 3: Development Environment

During development, your frontend typically runs on localhost:3000 while your API runs on localhost:8080. Different ports mean different origins, requiring CORS configuration.

// Development CORS config
const devCorsOptions = {
  origin: "http://localhost:3000",
  credentials: true,
};
 
// Or use environment-based config
const corsOptions = {
  origin: process.env.NODE_ENV === "production"
    ? "https://myapp.com"
    : "http://localhost:3000",
  credentials: true,
};

Use Case 4: WebSocket Connections

WebSocket connections are not subject to CORS in the same way as HTTP requests, but the initial HTTP upgrade request does include an Origin header that servers should validate.

import { WebSocketServer } from "ws";
 
const wss = new WebSocketServer({
  port: 8080,
  verifyClient: (info, callback) => {
    const origin = info.origin;
    const allowedOrigins = ["https://myapp.com", "http://localhost:3000"];
 
    if (allowedOrigins.includes(origin)) {
      callback(true);
    } else {
      callback(false, 403, "Origin not allowed");
    }
  },
});

Best Practices for Production

  1. Never use Access-Control-Allow-Origin: * with credentials: The browser rejects responses that have both * as the allowed origin and credentials: true. Always specify the exact origin when credentials are needed.

  2. Use the Vary: Origin header: When dynamically setting the allowed origin based on the request's Origin header, always include Vary: Origin to prevent caching issues. Without it, a CDN might cache a response with one origin's headers and serve it to a different origin.

  3. Validate origins against an allowlist: Don't blindly reflect the Origin header back in Access-Control-Allow-Origin. This defeats the purpose of CORS entirely. Maintain a strict allowlist of permitted origins.

  4. Set appropriate Access-Control-Max-Age: A large max-age value (like 86400 seconds for 24 hours) reduces the number of preflight requests your server handles. However, be aware that changes to CORS configuration won't take effect for clients that have cached the preflight response.

  5. Limit Access-Control-Allow-Methods and Access-Control-Allow-Headers: Only allow the methods and headers your API actually needs. Using * is convenient but less secure. Explicit enumeration is preferred for production environments.

  6. Don't forget OPTIONS in your routing: Many developers configure CORS headers for their actual endpoints but forget to handle OPTIONS requests for preflight. Make sure your CORS middleware runs before your route handlers.

  7. Use HTTPS for production origins: CORS with HTTP origins is acceptable in development but should never be used in production. Allowing http:// origins exposes your users to man-in-the-middle attacks.

  8. Monitor CORS-related errors: Log CORS violations in your application to detect potential attacks or misconfigurations. This data can help you identify issues before they affect users.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing Access-Control-Allow-Credentials headerCookies not sent with cross-origin requests, user appears logged outAdd credentials: true to both server CORS config and client fetch options
Using * wildcard with credentialsBrowser blocks response entirelySpecify exact origin dynamically from an allowlist
Forgetting Vary: OriginCDN serves cached response with wrong origin to different clientsAlways include Vary: Origin when setting dynamic origins
CORS headers on actual response but not preflightPreflight fails, actual request never sentEnsure CORS middleware runs before route handlers and handles OPTIONS
HTTPS frontend calling HTTP APIMixed content blocked by browserUse HTTPS for all origins or proxy through same-origin
Reflecting Origin header without validationCORS protection is completely bypassedValidate origin against a strict allowlist

Performance Optimization

CORS has measurable performance implications, primarily from preflight requests. Each preflight adds a round-trip to the server before the actual request can be sent. Here's how to minimize the impact:

// Set aggressive max-age for preflight caching
app.use(cors({
  origin: "https://myapp.com",
  maxAge: 86400, // Cache preflight for 24 hours
  // Only allow methods and headers actually used
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
}));
 
// Design APIs to use simple requests when possible
// GET with only simple headers - no preflight needed
fetch("/api/items?q=search")
  .then(res => res.json());
 
// Use POST with form-encoded data for simple mutations
// Content-Type: application/x-www-form-urlencoded is a simple header
const formData = new URLSearchParams();
formData.append("name", "value");
fetch("/api/items", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: formData,
});

Comparison with Alternatives

ApproachSecurityComplexityPerformanceUse Case
CORSBrowser-enforced, server-controlledMediumPreflight adds latencyStandard cross-origin API access
JSONPWeak (only GET, XSS risk)LowNo preflightLegacy systems, not recommended
Reverse ProxyServer-controlled, transparent to browserLow-NoneNo extra round tripsSame-origin proxy to backend
PostMessageWindow-level, manual validationHighNo preflightiframe communication
WebSocketOrigin header, server validationMediumPersistent connectionReal-time communication

For most modern applications, CORS is the correct approach. Use a reverse proxy when you need to avoid preflight overhead entirely.

Advanced Patterns

CORS with Token-Based Authentication

// Server: Validate JWT in CORS middleware
function corsWithAuth(req: Request, res: Response, next: NextFunction) {
  const origin = req.headers.origin;
 
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
 
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
    return res.status(204).send();
  }
 
  // Validate token on actual requests
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "No token provided" });
 
  try {
    req.user = jwt.verify(token, SECRET_KEY);
    next();
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
}

CORS with Rate Limiting

// Combine CORS with rate limiting per origin
const rateLimits = new Map<string, { count: number; resetTime: number }>();
 
function corsWithRateLimit(req: Request, res: Response, next: NextFunction) {
  const origin = req.headers.origin || "unknown";
 
  // Set CORS headers
  if (allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
 
  // Rate limit per origin
  const now = Date.now();
  const limit = rateLimits.get(origin) || { count: 0, resetTime: now + 60000 };
 
  if (now > limit.resetTime) {
    limit.count = 0;
    limit.resetTime = now + 60000;
  }
 
  limit.count++;
  rateLimits.set(origin, limit);
 
  res.setHeader("X-Rate-Limit-Remaining", String(100 - limit.count));
 
  if (limit.count > 100) {
    return res.status(429).json({ error: "Rate limit exceeded" });
  }
 
  next();
}

Testing Strategies

import request from "supertest";
import app from "../src/app";
 
describe("CORS Configuration", () => {
  it("allows requests from allowed origins", async () => {
    const res = await request(app)
      .get("/api/data")
      .set("Origin", "https://myapp.com");
 
    expect(res.headers["access-control-allow-origin"]).toBe("https://myapp.com");
    expect(res.status).toBe(200);
  });
 
  it("rejects requests from disallowed origins", async () => {
    const res = await request(app)
      .get("/api/data")
      .set("Origin", "https://malicious.com");
 
    expect(res.headers["access-control-allow-origin"]).toBeUndefined();
  });
 
  it("handles preflight requests correctly", async () => {
    const res = await request(app)
      .options("/api/data")
      .set("Origin", "https://myapp.com")
      .set("Access-Control-Request-Method", "PUT")
      .set("Access-Control-Request-Headers", "Content-Type");
 
    expect(res.status).toBe(204);
    expect(res.headers["access-control-allow-methods"]).toContain("PUT");
    expect(res.headers["access-control-allow-headers"]).toContain("Content-Type");
  });
 
  it("includes Vary: Origin header for dynamic origins", async () => {
    const res = await request(app)
      .get("/api/data")
      .set("Origin", "https://myapp.com");
 
    expect(res.headers["vary"]).toBe("Origin");
  });
 
  it("supports credentials", async () => {
    const res = await request(app)
      .get("/api/data")
      .set("Origin", "https://myapp.com")
      .set("Cookie", "session=abc123");
 
    expect(res.headers["access-control-allow-credentials"]).toBe("true");
  });
});

Future Outlook

The web platform continues to evolve around cross-origin security. The Private Network Access proposal (formerly CORS-RFC1918) extends CORS to cover requests from public networks to private networks (e.g., localhost, 192.168.x.x). This means that in the future, web pages on public websites will need explicit permission from devices on private networks before they can communicate with them.

Additionally, the Fetch Metadata Request Headers specification provides servers with more information about the context of a request (e.g., whether it's from a same-site navigation, a cross-site script, or a user-initiated link), enabling more nuanced access control decisions.

Conclusion

CORS is a critical security mechanism that enables controlled cross-origin communication in web applications. Understanding the difference between simple and preflight requests, knowing which headers to set and when, and following security best practices will help you build robust APIs that work seamlessly across domains.

Key takeaways:

  1. CORS is a browser-enforced mechanism; servers set headers, browsers enforce them
  2. Simple requests (GET/HEAD/POST with basic headers) don't trigger preflight
  3. Custom headers, PUT/DELETE/PATCH, and JSON content types trigger preflight OPTIONS requests
  4. Never reflect the Origin header without validation — use an explicit allowlist
  5. Use Vary: Origin when dynamically setting the allowed origin
  6. Set appropriate Access-Control-Max-Age to reduce preflight overhead
  7. Consider a reverse proxy to eliminate CORS complexity entirely for internal services

Master these concepts and you'll handle cross-origin communication confidently in any web application.