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.
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 portThe 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.
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-KeyCore 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 = 86400Go / 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)
})
}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
-
Always validate origin against an allowlist: Never blindly reflect the
Originheader. Create aSetof allowed origins and check membership. This prevents any random website from making authenticated requests to your API on behalf of your users. -
Set
Vary: Originfor dynamic origins: When you return differentAccess-Control-Allow-Originvalues for different request origins, CDNs and proxies need to know that the response varies by theOriginrequest header. WithoutVary: Origin, a CDN might serve a cached response with the wrong allowed origin. -
Use appropriate
Max-Agevalues: SetAccess-Control-Max-Ageto 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. -
Separate CORS for different environments: Use environment variables to control allowed origins. Development allows
localhost, production allows only your deployed domains. Never allowlocalhostin production CORS configurations. -
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.
-
Consider using
SameSitecookies: Modern browsers default toSameSite=Laxfor cookies, which prevents cross-site sending. If your CORS setup requires credentials, ensure your cookies are set withSameSite=None; Secure. -
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
fetchin the console. -
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
| Pitfall | Impact | Solution |
|---|---|---|
Missing Access-Control-Allow-Credentials | Cookies not sent cross-origin; auth fails silently | Add credentials: true on both server and client (fetch options) |
Wildcard * with credentials | Browser blocks response entirely | Specify exact origin; never use * with credentials |
| Forgetting preflight handling | PUT/DELETE requests fail with CORS error | Handle OPTIONS method in middleware before route handlers |
Missing Vary: Origin | CDN serves wrong origin's cached response | Always add Vary: Origin when setting dynamic origins |
Not setting Access-Control-Allow-Headers | Custom headers rejected in preflight response | List all custom headers your API accepts |
| SSL/HTTPS mismatch | Mixed content blocked | Ensure 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 endpointComparison with Alternatives
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| CORS | Browser-enforced, standard, fine-grained | Preflight latency, complexity | Public APIs, SPA + API architecture |
| Reverse Proxy | No preflight, simple | Server infrastructure needed, routing complexity | Internal services, same-domain setups |
| BFF (Backend for Frontend) | Single domain, optimized responses | Extra server to maintain | Complex microservices |
| GraphQL Federation | Single endpoint, no CORS per query | Learning curve, complexity | Multi-service APIs |
| JSONP | No preflight, wide support | GET only, XSS risk, obsolete | Legacy 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:
- CORS is browser-enforced — it doesn't affect server-to-server or non-browser clients
- Simple requests (GET/HEAD/POST with basic headers) go through directly
- Non-simple requests require a preflight OPTIONS handshake
- Always validate origins against an explicit allowlist
- Use
Vary: Originwhen responding with dynamic origins - Set
Access-Control-Max-Ageto cache preflight responses - Handle OPTIONS requests before your route handlers
- Test with actual browser requests, not Postman
With these principles, CORS becomes a straightforward configuration task rather than a source of frustration.