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.
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 differsThe 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 requestThe 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.
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" }),
};
};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
-
Never use
Access-Control-Allow-Origin: *with credentials: The browser rejects responses that have both*as the allowed origin andcredentials: true. Always specify the exact origin when credentials are needed. -
Use the
Vary: Originheader: When dynamically setting the allowed origin based on the request'sOriginheader, always includeVary: Originto prevent caching issues. Without it, a CDN might cache a response with one origin's headers and serve it to a different origin. -
Validate origins against an allowlist: Don't blindly reflect the
Originheader back inAccess-Control-Allow-Origin. This defeats the purpose of CORS entirely. Maintain a strict allowlist of permitted origins. -
Set appropriate
Access-Control-Max-Age: A largemax-agevalue (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. -
Limit
Access-Control-Allow-MethodsandAccess-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. -
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.
-
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. -
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
| Pitfall | Impact | Solution |
|---|---|---|
Missing Access-Control-Allow-Credentials header | Cookies not sent with cross-origin requests, user appears logged out | Add credentials: true to both server CORS config and client fetch options |
Using * wildcard with credentials | Browser blocks response entirely | Specify exact origin dynamically from an allowlist |
Forgetting Vary: Origin | CDN serves cached response with wrong origin to different clients | Always include Vary: Origin when setting dynamic origins |
| CORS headers on actual response but not preflight | Preflight fails, actual request never sent | Ensure CORS middleware runs before route handlers and handles OPTIONS |
| HTTPS frontend calling HTTP API | Mixed content blocked by browser | Use HTTPS for all origins or proxy through same-origin |
| Reflecting Origin header without validation | CORS protection is completely bypassed | Validate 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
| Approach | Security | Complexity | Performance | Use Case |
|---|---|---|---|---|
| CORS | Browser-enforced, server-controlled | Medium | Preflight adds latency | Standard cross-origin API access |
| JSONP | Weak (only GET, XSS risk) | Low | No preflight | Legacy systems, not recommended |
| Reverse Proxy | Server-controlled, transparent to browser | Low-None | No extra round trips | Same-origin proxy to backend |
| PostMessage | Window-level, manual validation | High | No preflight | iframe communication |
| WebSocket | Origin header, server validation | Medium | Persistent connection | Real-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:
- CORS is a browser-enforced mechanism; servers set headers, browsers enforce them
- Simple requests (GET/HEAD/POST with basic headers) don't trigger preflight
- Custom headers, PUT/DELETE/PATCH, and JSON content types trigger preflight OPTIONS requests
- Never reflect the Origin header without validation — use an explicit allowlist
- Use
Vary: Originwhen dynamically setting the allowed origin - Set appropriate
Access-Control-Max-Ageto reduce preflight overhead - 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.