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: Cross-Origin Resource Sharing

Demystify CORS: preflight requests, headers, common errors, and server configuration.

CORSSecurityHTTPBackend

By MinhVo

Introduction

Cross-Origin Resource Sharing (CORS) errors are the bane of every web developer's existence. You build a perfectly functional API, hook it up to your frontend, and suddenly the browser throws a wall of red errors about "No 'Access-Control-Allow-Origin' header is present on the requested resource." The frustration is compounded by the fact that the same request works perfectly in Postman, curl, or any non-browser client.

The reason is simple: CORS is a browser-enforced security feature. It's not a server-side restriction — it's the browser protecting your users from malicious scripts. Understanding this fundamental distinction transforms CORS from an obstacle into a powerful tool for building secure, multi-domain web applications. In this guide, we'll demystify every aspect of CORS configuration, from the basic headers to advanced patterns for microservices architectures.

CORS Security Architecture

Understanding CORS: Core Concepts

CORS exists because of the same-origin policy — a fundamental security mechanism built into every modern browser. The same-origin policy prevents a web application served from one origin from interacting with resources from a different origin. This prevents a malicious website from reading your bank's API responses or modifying your data on another service.

What Defines an Origin

An origin is the tuple of (scheme, host, port). Two URLs share the same origin only if all three components match exactly. This means https://example.com and http://example.com are different origins (different scheme), as are https://example.com and https://example.com:8080 (different port).

// Same origin
new URL("https://example.com").origin === new URL("https://example.com/page").origin
// true
 
// Different origins
new URL("https://example.com").origin === new URL("http://example.com").origin
// false — different scheme
 
new URL("https://example.com").origin === new URL("https://api.example.com").origin
// false — different host
 
new URL("https://example.com").origin === new URL("https://example.com:8080").origin
// false — different port

The CORS Request Flow

When a browser makes a cross-origin request, the following process occurs. First, the browser checks whether the request is "simple" or requires a preflight. For simple requests, the browser adds an Origin header and sends the request directly. The server responds with the resource and CORS headers. The browser then checks the CORS headers — if Access-Control-Allow-Origin matches the request origin, the response is delivered to JavaScript. If not, the browser blocks the response.

For non-simple requests (those with custom headers, PUT/DELETE methods, or JSON content types), the browser first sends a preflight OPTIONS request. This preflight includes the intended method, headers, and origin. The server responds with its CORS policy. If the policy allows the request, the browser sends the actual request. If not, the browser blocks the request entirely.

// Simple request flow (GET with no custom headers):
// Browser → GET /api/data + Origin: https://myapp.com
// Server → 200 OK + Access-Control-Allow-Origin: https://myapp.com + data
// Browser → delivers response to JavaScript ✓
 
// Preflight request flow (PUT with JSON body):
// Browser → OPTIONS /api/data + Origin + Request-Method + Request-Headers
// Server → 204 + Allow-Origin + Allow-Methods + Allow-Headers
// Browser → PUT /api/data + Origin + Content-Type: application/json + body
// Server → 200 OK + Access-Control-Allow-Origin + data
// Browser → delivers response to JavaScript ✓

Why Postman Works but the Browser Doesn't

This is the most common question about CORS. Postman, curl, and other HTTP clients don't enforce CORS because they're not browsers. CORS is enforced by the browser's JavaScript engine — it's a protection for users visiting websites, not for APIs. The browser checks CORS headers on responses before making them available to JavaScript. Other tools simply deliver the response regardless of headers.

Browser Security Model

Architecture and CORS Header Reference

Core Request Headers (Set by the Browser)

// Origin — sent automatically by the browser on all cross-origin requests
// Origin: https://myapp.com
 
// Access-Control-Request-Method — sent on preflight, indicates intended method
// Access-Control-Request-Method: PUT
 
// Access-Control-Request-Headers — sent on preflight, lists custom headers
// Access-Control-Request-Headers: Content-Type, Authorization, X-Api-Key

Core Response Headers (Set by the Server)

// Access-Control-Allow-Origin — which origin can access the resource
res.setHeader("Access-Control-Allow-Origin", "https://myapp.com");
// Or for any origin (cannot be used with credentials):
res.setHeader("Access-Control-Allow-Origin", "*");
 
// Access-Control-Allow-Methods — allowed HTTP methods
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH");
 
// Access-Control-Allow-Headers — allowed request headers
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
 
// Access-Control-Allow-Credentials — whether credentials are allowed
res.setHeader("Access-Control-Allow-Credentials", "true");
 
// Access-Control-Expose-Headers — additional headers visible to JavaScript
res.setHeader("Access-Control-Expose-Headers", "X-Total-Count, X-Request-Id");
 
// Access-Control-Max-Age — how long to cache preflight response (seconds)
res.setHeader("Access-Control-Max-Age", "86400");
 
// Vary — tells caches to vary by Origin
res.setHeader("Vary", "Origin");

Simple Request Criteria

A request is "simple" if it meets ALL of these conditions:

// Simple methods:
// GET, HEAD, POST
 
// Simple Content-Type values:
"application/x-www-form-urlencoded"
"multipart/form-data"
"text/plain"
 
// Simple headers (no custom headers):
Accept
Accept-Language
Content-Language
Content-Type (with simple values above)
 
// Everything else triggers a preflight:
// - PUT, DELETE, PATCH methods
// - Content-Type: application/json
// - Authorization header
// - Any custom header (X-Api-Key, X-Request-Id, etc.)

Step-by-Step Implementation

Node.js / Express Setup

import express from "express";
import helmet from "helmet";
 
const app = express();
 
// Development: allow localhost
if (process.env.NODE_ENV === "development") {
  app.use((req, res, next) => {
    res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
 
    if (req.method === "OPTIONS") {
      res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH");
      res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
      res.setHeader("Access-Control-Max-Age", "86400");
      return res.sendStatus(204);
    }
    next();
  });
}
 
// Production: strict allowlist
const productionOrigins = new Set([
  "https://myapp.com",
  "https://www.myapp.com",
  "https://admin.myapp.com",
]);
 
if (process.env.NODE_ENV === "production") {
  app.use((req, res, next) => {
    const origin = req.headers.origin;
    if (origin && productionOrigins.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,PATCH");
      res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
      res.setHeader("Access-Control-Max-Age", "86400");
      return res.sendStatus(204);
    }
    next();
  });
}

Next.js API Routes

// pages/api/[...slug].ts or app/api/[...slug]/route.ts
import type { NextApiRequest, NextApiResponse } from "next";
 
const allowedOrigins = ["https://myapp.com", "http://localhost:3000"];
 
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const origin = req.headers.origin;
 
  if (origin && allowedOrigins.includes(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", "Content-Type,Authorization");
    res.setHeader("Access-Control-Max-Age", "86400");
    return res.status(204).end();
  }
 
  // Handle actual request
  res.json({ message: "Success" });
}
 
// Or use Next.js middleware for all API routes
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const origin = request.headers.get("origin");
  const response = NextResponse.next();
 
  if (origin && allowedOrigins.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set("Vary", "Origin");
    response.headers.set("Access-Control-Allow-Credentials", "true");
  }
 
  if (request.method === "OPTIONS") {
    response.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
    response.headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
    response.headers.set("Access-Control-Max-Age", "86400");
    return new NextResponse(null, { status: 204, headers: response.headers });
  }
 
  return response;
}
 
export const config = {
  matcher: "/api/:path*",
};

Django / Python

# settings.py
INSTALLED_APPS = [
    "corsheaders",
    # ...
]
 
MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]
 
CORS_ALLOWED_ORIGINS = [
    "https://myapp.com",
    "https://admin.myapp.com",
]
 
CORS_ALLOW_CREDENTIALS = True
 
CORS_ALLOW_HEADERS = [
    "accept",
    "authorization",
    "content-type",
    "x-requested-with",
]
 
CORS_PREFLIGHT_MAX_AGE = 86400

Go / net/http

package main
 
import (
    "net/http"
    "strings"
)
 
var allowedOrigins = map[string]bool{
    "https://myapp.com":     true,
    "https://admin.myapp.com": true,
}
 
func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")
 
        if allowedOrigins[origin] {
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Vary", "Origin")
            w.Header().Set("Access-Control-Allow-Credentials", "true")
        }
 
        if r.Method == "OPTIONS" {
            w.Header().Set("Access-Control-Allow-Methods",
                "GET, POST, PUT, DELETE, PATCH")
            w.Header().Set("Access-Control-Allow-Headers",
                "Content-Type, Authorization")
            w.Header().Set("Access-Control-Max-Age", "86400")
            w.WriteHeader(http.StatusNoContent)
            return
        }
 
        next.ServeHTTP(w, r)
    })
}

Server Configuration

Real-World Use Cases

Use Case 1: Microservices Architecture

In a microservices architecture, the frontend might need to communicate with multiple backend services, each on a different subdomain.

// API Gateway CORS configuration
const serviceOrigins = {
  "https://api.myapp.com": ["https://myapp.com", "https://admin.myapp.com"],
  "https://payments.myapp.com": ["https://myapp.com"],
  "https://auth.myapp.com": ["https://myapp.com", "https://admin.myapp.com"],
};
 
function corsForService(serviceHost: string) {
  const origins = serviceOrigins[serviceHost] || [];
 
  return (req: Request, res: Response, next: NextFunction) => {
    const origin = req.headers.origin;
    if (origin && origins.includes(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", "Content-Type,Authorization");
      res.setHeader("Access-Control-Max-Age", "3600");
      return res.sendStatus(204);
    }
    next();
  };
}

Use Case 2: CDN and Static Assets

When serving assets from a CDN, CORS headers are necessary for fonts, WebGL textures, and fetch-based asset loading.

# Nginx config for CDN assets with CORS
location /assets/ {
    add_header Access-Control-Allow-Origin *;
    add_header Cache-Control "public, max-age=31536000, immutable";
 
    # Fonts need specific CORS headers
    location ~* \.(woff|woff2|ttf|otf)$ {
        add_header Access-Control-Allow-Origin *;
        add_header Content-Type "font/woff2";
    }
}

Use Case 3: Server-Sent Events

Server-Sent Events (SSE) require CORS headers since the browser makes a cross-origin long-lived connection.

app.get("/api/events", (req, res) => {
  const origin = req.headers.origin;
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
 
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
 
  const sendEvent = (data: object) => {
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };
 
  // Send events...
  const interval = setInterval(() => {
    sendEvent({ timestamp: Date.now() });
  }, 1000);
 
  req.on("close", () => {
    clearInterval(interval);
  });
});

Best Practices for Production

  1. Always validate origin against an allowlist: Never blindly reflect the Origin header. Create a Set of allowed origins and check membership. This prevents any random website from making authenticated requests to your API on behalf of your users.

  2. Set Vary: Origin for dynamic origins: When you return different Access-Control-Allow-Origin values for different request origins, CDNs and proxies need to know that the response varies by the Origin request header. Without Vary: Origin, a CDN might serve a cached response with the wrong allowed origin.

  3. Use appropriate Max-Age values: Set Access-Control-Max-Age to reduce preflight overhead. For APIs with stable CORS policies, 86400 (24 hours) is a good default. For rapidly changing configurations during development, use shorter values.

  4. Separate CORS for different environments: Use environment variables to control allowed origins. Development allows localhost, production allows only your deployed domains. Never allow localhost in production CORS configurations.

  5. Handle errors gracefully: When a CORS request fails, provide meaningful error messages in your server logs. Don't just return a generic 403 — log which origin was rejected and why.

  6. Consider using SameSite cookies: Modern browsers default to SameSite=Lax for cookies, which prevents cross-site sending. If your CORS setup requires credentials, ensure your cookies are set with SameSite=None; Secure.

  7. Test with actual browser requests: Don't test CORS with Postman or curl — they don't enforce CORS. Use the actual browser, browser DevTools, or tools like fetch in the console.

  8. Document your CORS policy: Maintain a clear document listing which origins are allowed to access which endpoints. This is especially important for APIs consumed by third parties.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing Access-Control-Allow-CredentialsCookies not sent cross-origin; auth fails silentlyAdd credentials: true on both server and client (fetch options)
Wildcard * with credentialsBrowser blocks response entirelySpecify exact origin; never use * with credentials
Forgetting preflight handlingPUT/DELETE requests fail with CORS errorHandle OPTIONS method in middleware before route handlers
Missing Vary: OriginCDN serves wrong origin's cached responseAlways add Vary: Origin when setting dynamic origins
Not setting Access-Control-Allow-HeadersCustom headers rejected in preflight responseList all custom headers your API accepts
SSL/HTTPS mismatchMixed content blockedEnsure all production origins use HTTPS

Performance Optimization

Preflight requests add latency. Here's how to minimize their impact:

// 1. Set long max-age for preflight caching
res.setHeader("Access-Control-Max-Age", "86400");
 
// 2. Design APIs to use simple requests when possible
// This GET request has no preflight:
fetch("/api/users?page=1&limit=20");
 
// 3. Batch mutations to reduce preflight count
// Instead of multiple PUT requests:
fetch("/api/batch", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    operations: [
      { method: "PUT", path: "/users/1", body: { name: "New" } },
      { method: "DELETE", path: "/users/2" },
    ],
  }),
});
 
// 4. Use service worker to add auth headers same-origin
// Register a service worker that adds Authorization headers
// to same-origin requests to your proxy endpoint

Comparison with Alternatives

ApproachProsConsBest For
CORSBrowser-enforced, standard, fine-grainedPreflight latency, complexityPublic APIs, SPA + API architecture
Reverse ProxyNo preflight, simpleServer infrastructure needed, routing complexityInternal services, same-domain setups
BFF (Backend for Frontend)Single domain, optimized responsesExtra server to maintainComplex microservices
GraphQL FederationSingle endpoint, no CORS per queryLearning curve, complexityMulti-service APIs
JSONPNo preflight, wide supportGET only, XSS risk, obsoleteLegacy systems only

For most modern web applications, CORS is the standard approach. Use a reverse proxy or BFF when preflight latency becomes a measurable problem.

Advanced Patterns

CORS with Preflight Caching at the Edge

// Cache preflight responses at CDN level
app.options("/api/*", (req, res) => {
  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-Methods", "GET,POST,PUT,DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
    res.setHeader("Access-Control-Max-Age", "86400");
    res.setHeader("Cache-Control", "public, max-age=86400, s-maxage=86400");
  }
  res.sendStatus(204);
});

Per-Route CORS Configuration

// Different CORS policies for different routes
const publicCors = {
  origin: "*",
  methods: ["GET"],
};
 
const authenticatedCors = {
  origin: allowedOrigins,
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
};
 
app.get("/api/public/*", cors(publicCors), publicHandler);
app.use("/api/*", cors(authenticatedCors), authMiddleware, apiHandler);

CORS Error Logging and Monitoring

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  if (err.message === "Not allowed by CORS") {
    logger.warn("CORS violation", {
      origin: req.headers.origin,
      method: req.method,
      path: req.path,
      ip: req.ip,
      userAgent: req.headers["user-agent"],
    });
    return res.status(403).json({
      error: "CORS_NOT_ALLOWED",
      message: "Origin not permitted by CORS policy",
    });
  }
  next(err);
});

Testing Strategies

import request from "supertest";
import app from "../src/app";
 
describe("CORS", () => {
  describe("Simple requests", () => {
    it("returns CORS headers for allowed origin", 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.headers["access-control-allow-credentials"]).toBe("true");
      expect(res.headers["vary"]).toBe("Origin");
    });
 
    it("does not set CORS headers for disallowed origin", async () => {
      const res = await request(app)
        .get("/api/data")
        .set("Origin", "https://evil.com");
 
      expect(res.headers["access-control-allow-origin"]).toBeUndefined();
    });
  });
 
  describe("Preflight requests", () => {
    it("responds to OPTIONS with correct headers", 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,Authorization");
 
      expect(res.status).toBe(204);
      expect(res.headers["access-control-allow-methods"]).toContain("PUT");
      expect(res.headers["access-control-allow-headers"]).toContain("Authorization");
      expect(res.headers["access-control-max-age"]).toBeDefined();
    });
 
    it("rejects preflight for disallowed methods", async () => {
      const res = await request(app)
        .options("/api/data")
        .set("Origin", "https://myapp.com")
        .set("Access-Control-Request-Method", "PATCH");
 
      // PATCH not in allowed methods
      expect(res.headers["access-control-allow-methods"]).not.toContain("PATCH");
    });
  });
 
  describe("Credentials", () => {
    it("includes allow-credentials header", 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 evolving cross-origin security. Private Network Access extends CORS to protect devices on private networks from public web pages. Fetch Metadata headers provide servers with request context (same-site vs cross-site, navigation vs script). The Permissions Policy header gives fine-grained control over browser features.

These additions mean CORS will become even more nuanced. The core mechanism remains stable, but developers will need to stay aware of new headers and policies that affect cross-origin communication.

Conclusion

CORS is not magic and it's not a bug — it's a security feature that protects your users. By understanding the request flow, setting the right headers, and validating origins properly, you can build secure cross-origin communication that works reliably across browsers and devices.

Key takeaways:

  1. CORS is browser-enforced — it doesn't affect server-to-server or non-browser clients
  2. Simple requests (GET/HEAD/POST with basic headers) go through directly
  3. Non-simple requests require a preflight OPTIONS handshake
  4. Always validate origins against an explicit allowlist
  5. Use Vary: Origin when responding with dynamic origins
  6. Set Access-Control-Max-Age to cache preflight responses
  7. Handle OPTIONS requests before your route handlers
  8. Test with actual browser requests, not Postman

With these principles, CORS becomes a straightforward configuration task rather than a source of frustration.