CORS: The Complete Handbook for Modern Web APIs#
Cross-Origin Resource Sharing, or CORS, is one of the most misunderstood parts of web development. Teams lose hours to it because the browser error messages feel vague, framework defaults vary wildly, and many blog posts reduce the topic to “just add Access-Control-Allow-Origin: *”.
That advice is often wrong.
CORS is not an authentication system, not a CSRF defense, and not a server-to-server access control mechanism. It is a browser-enforced policy layer that decides whether frontend JavaScript running on one origin may read a response from another origin.
If you build APIs, SPAs, micro-frontends, SaaS integrations, admin panels, GraphQL endpoints, serverless functions, or cookie-based session apps, you need to understand CORS well enough to configure it deliberately. This handbook is opinionated, practical, and current for 2026.
What CORS actually is#
CORS is a browser mechanism built on top of the Same-Origin Policy. The browser sends cross-origin requests all the time, but it only exposes the response to frontend JavaScript when the server opts in with specific HTTP headers.
A key point:
- CORS is enforced by browsers
- CORS does not stop
curl, Postman, backend services, or malicious scripts running outside the browser - CORS does not replace authentication or authorization
- CORS controls who can read a response in browser JavaScript
If your API is “protected by CORS”, it is not protected.
The Same-Origin Policy, clearly explained#
The Same-Origin Policy, or SOP, is the browser security model that restricts how documents and scripts from one origin can interact with resources from another origin.
An origin is defined by:
- scheme:
httpvshttps - host:
app.example.comvsapi.example.com - port:
3000vs8080
These are different origins:
https://example.comandhttp://example.comhttps://example.comandhttps://api.example.comhttps://example.comandhttps://example.com:8443
These are same-origin:
https://example.com/page1https://example.com/api/users
The path does not matter for origin comparison.
Why SOP exists#
Without SOP, any website you visit could silently read data from:
- your bank
- your company admin panel
- your webmail
- your cloud dashboard
If your browser sends cookies to those sites, a malicious page could harvest the responses. SOP blocks that by default.
What SOP blocks and what it does not#
SOP mainly restricts reading cross-origin responses from JavaScript. It does not stop all cross-origin requests from being sent.
A browser may still allow:
- loading images from another origin
- loading scripts from another origin
- submitting forms to another origin
- embedding iframes from another origin
But JavaScript on https://app.example.com cannot freely read https://api.example.com/data unless CORS allows it.
That distinction matters because many security bugs happen when teams confuse “request can be sent” with “response can be read”.
A mental model for CORS#
Use this model:
- A page on Origin A makes a request to Origin B.
- The browser decides whether the request is “simple” or needs a preflight.
- The server at Origin B returns CORS headers.
- The browser checks the headers against the request context.
- If valid, JavaScript gets access to the response.
- If invalid, the request may still reach the server, but the browser blocks frontend code from reading the result.
The server is not “talking to CORS”. The browser is interpreting the server’s headers.
Simple requests vs preflight requests#
This is where most confusion begins.
Simple requests#
A “simple request” is a cross-origin request that meets strict conditions. If it qualifies, the browser sends it directly without a preflight OPTIONS request first.
Typical examples:
GETHEADPOSTwith limited content types and safe headers
Conditions for a simple request#
A request is simple only if:
- Method is
GET,HEAD, orPOST - Manually set headers are limited to CORS-safelisted headers
Content-Type, if set, is one of:application/x-www-form-urlencodedmultipart/form-datatext/plain
Headers commonly allowed in simple requests include:
AcceptAccept-LanguageContent-LanguageContent-Typewith the limited values above
If you send:
Content-Type: application/jsonAuthorization: Bearer ...- custom headers like
X-API-Key
the browser usually performs a preflight first.
Example: simple request#
Frontend:
fetch("https://api.example.com/public-posts", {
method: "GET"
})
.then(r => r.json())
.then(console.log)
.catch(console.error);Server response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/jsonIf the page origin is https://app.example.com, the browser allows JavaScript to read the response.
Preflight requests#
A preflight is an automatic OPTIONS request the browser sends before the actual request when the cross-origin request is considered potentially unsafe or non-simple.
The preflight asks the server:
- Do you allow this origin?
- Do you allow this method?
- Do you allow these headers?
Common triggers for preflight#
Any of these often trigger preflight:
PUT,PATCH,DELETEPOSTwithContent-Type: application/jsonAuthorizationheader- custom headers like
X-Requested-With,X-Tenant-ID - some nonstandard request setups
Example: browser preflight#
Frontend:
fetch("https://api.example.com/users/123", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer token123"
},
body: JSON.stringify({ name: "Ava" })
});The browser may first send:
OPTIONS /users/123 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type, authorizationThe server should respond with something like:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600If that passes, the browser sends the actual PATCH.
Critical point#
If your server handles PATCH correctly but does not handle OPTIONS, the request may fail before your real endpoint logic even runs.
That is one of the most common CORS bugs in production.
Every CORS header that matters#
A complete CORS setup depends on understanding each header’s role.
Origin#
This is a request header sent by the browser on cross-origin requests.
Example:
Origin: https://app.example.comThe server uses this to decide whether to allow the requesting origin.
Notes:
- Browsers send
Originon CORS requests - Some same-origin or special cases may differ
- Do not trust
Originas an auth mechanism by itself
Access-Control-Allow-Origin#
This response header tells the browser which origin may read the response.
Examples:
Access-Control-Allow-Origin: https://app.example.comor
Access-Control-Allow-Origin: *Best practice#
Prefer explicit origins for authenticated or sensitive APIs.
Use * only for truly public resources that:
- do not require credentials
- expose no user-specific data
- are intentionally readable by any website
Important rule#
You cannot use:
Access-Control-Allow-Origin: *together with:
Access-Control-Allow-Credentials: trueBrowsers reject that combination.
Access-Control-Allow-Methods#
Used mainly in preflight responses. It tells the browser which methods are allowed for the target resource.
Example:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONSNotes:
- This does not grant permissions by itself
- Your application must still enforce route-level authorization
- Include
OPTIONSif your framework requires explicit handling
Access-Control-Allow-Headers#
Used mainly in preflight responses. It lists which request headers the browser may send.
Example:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Tenant-IDIf the browser wants to send Authorization and your preflight response does not allow it, the real request will never be sent.
Common mistake#
Teams forget to allow Authorization, then wonder why bearer-token requests fail only in browsers.
Access-Control-Allow-Credentials#
This tells the browser it may expose the response to frontend code when the request includes credentials.
Example:
Access-Control-Allow-Credentials: trueCredentials include:
- cookies
- HTTP auth
- TLS client certificates
- fetch/XHR requests with credentials mode enabled
If you use this header:
Access-Control-Allow-Originmust be a specific origin, not*- your frontend must also opt in to sending credentials
Frontend example:
fetch("https://api.example.com/me", {
credentials: "include"
});Access-Control-Expose-Headers#
By default, browser JavaScript can only read a limited set of response headers from a cross-origin response.
If you want JS to access custom headers, expose them.
Example:
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, Content-DispositionFrontend:
const res = await fetch("https://api.example.com/report");
console.log(res.headers.get("X-Request-ID"));Without Access-Control-Expose-Headers, many custom headers are invisible to browser JS even though they exist in the network response.
Good use cases#
Expose:
X-Request-ID- rate-limit headers
- pagination metadata headers
Content-Dispositionif the frontend needs filename info
Access-Control-Max-Age#
This tells the browser how long it may cache the preflight result.
Example:
Access-Control-Max-Age: 3600That means the browser may reuse the preflight decision for 1 hour for matching requests.
This can reduce latency and server load significantly.
Reality check#
Browsers may impose their own upper limits. Do not assume a giant value like a week will always be honored exactly.
A practical range is often:
600seconds for frequently changing policies3600to86400for stable APIs
Access-Control-Request-Method#
This is a request header sent by the browser during preflight.
Example:
Access-Control-Request-Method: PATCHIt tells the server which actual method the browser intends to use.
Access-Control-Request-Headers#
This is a request header sent by the browser during preflight listing non-simple headers it wants to send.
Example:
Access-Control-Request-Headers: content-type, authorization, x-tenant-idThe server should validate and respond with an allowed set.
Vary: Origin#
This is not a CORS-specific header, but it is essential when you dynamically reflect allowed origins.
Example:
Vary: OriginWhy it matters:
If your CDN or cache serves responses for multiple origins and your server returns different Access-Control-Allow-Origin values per request, failing to set Vary: Origin can cause one origin’s CORS headers to be cached and incorrectly served to another.
That leads to broken access or, worse, unintended exposure.
If you reflect origins dynamically, set Vary: Origin.
Preflight caching, correctly understood#
Preflight caching is one of the easiest performance wins in CORS.
Without it, every non-simple request may involve:
OPTIONS- actual request
That doubles round trips.
How it works#
The browser caches successful preflight responses based on factors like:
- requesting origin
- target URL
- method
- requested headers
If the next request matches the cached policy, the browser skips the preflight until the cache expires.
Example#
Server preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 7200
Vary: OriginOpinionated guidance#
- Set
Access-Control-Max-Ageunless you have a strong reason not to 3600is a sane default- If your policy is stable, go higher
- If you frequently change allowed headers or methods, keep it modest
What not to do#
Do not rely on preflight caching to paper over bad API design. If your frontend sends ten custom headers for no reason, you still have a needlessly expensive request shape.
Credentials: cookies, auth, and the trap everyone hits#
Credentialed CORS is where many real-world apps break.
What counts as credentials#
In CORS terms, credentials include:
- cookies
- HTTP basic/digest auth
- TLS client certs
For fetch, credentials are controlled by the credentials option:
omitsame-origininclude
Example:
fetch("https://api.example.com/session", {
credentials: "include"
});Requirements for credentialed CORS#
For browser JS to read a credentialed cross-origin response:
- Frontend must send credentials
- Server must return:
Access-Control-Allow-Credentials: true- a specific
Access-Control-Allow-Origin
- Cookies themselves must be configured correctly
Cookie gotcha for cross-site usage#
Modern browsers require cross-site cookies to use:
Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnlyIf you forget SameSite=None; Secure, your cookie may never be sent cross-site even if CORS is perfect.
That is not a CORS bug. It is a cookie policy issue.
Example: session-based SPA#
Frontend on https://app.example.com:
await fetch("https://api.example.com/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "[email protected]",
password: "secret"
})
});Server response headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None
Vary: OriginOpinionated advice#
If you can avoid cross-site cookies for public APIs, do. Token-based auth is often simpler operationally.
If you need cookie-based auth for web apps, keep your allowed origins tight and understand CSRF separately. CORS does not solve CSRF.
Wildcard origins: when * is acceptable and when it is reckless#
Access-Control-Allow-Origin: * is not evil. It is just overused.
Acceptable uses#
Use * for resources that are intentionally public and identical for all users:
- open public APIs with no auth
- public metadata endpoints
- static public config
- public images or fonts where CORS read access is intended
Example:
Access-Control-Allow-Origin: *Bad uses#
Do not use * for:
- authenticated APIs
- user-specific data
- admin APIs
- internal tools
- anything with cookies or credentialed access
Better pattern#
If you have a known frontend list, use an allowlist and reflect only exact matches.
Bad:
if (origin.includes("example.com")) allow = true;Good:
const allowed = new Set([
"https://app.example.com",
"https://admin.example.com"
]);Substring matching is sloppy and dangerous. https://evil-example.com is not your app.
Real browser flow examples#
Example 1: public GET#
Frontend:
fetch("https://api.example.com/public/news");Server:
Access-Control-Allow-Origin: *Works, no credentials.
Example 2: authenticated JSON POST#
Frontend:
fetch("https://api.example.com/orders", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ sku: "A1", qty: 2 })
});Browser likely preflights.
Server must handle:
OPTIONSAccess-Control-Allow-Origin: https://shop.example.comAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Headers: Content-Type- cookie with
SameSite=None; Secure
CORS in Express#
Express remains one of the most common places developers meet CORS.
Basic setup with cors package#
npm install corsconst express = require("express");
const cors = require("cors");
const app = express();
app.use(express.json());
app.use(cors({
origin: "https://app.example.com"
}));
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);Credentialed setup#
const express = require("express");
const cors = require("cors");
const app = express();
app.use(express.json());
app.use(cors({
origin: "https://app.example.com",
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-Request-ID"],
maxAge: 3600
}));
app.options("*", cors());
app.get("/api/me", (req, res) => {
res.setHeader("X-Request-ID", "req_123");
res.json({ user: "Ava" });
});
app.listen(3000);Dynamic allowlist#
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
app.use(cors({
origin(origin, callback) {
if (!origin) return callback(null, true); // non-browser or same-origin cases
if (allowedOrigins.has(origin)) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
credentials: true
}));Opinionated Express note#
The cors middleware is fine, but many teams stop thinking after installing it. Review the actual headers in production, especially behind proxies, CDNs, and API gateways.
CORS in FastAPI#
FastAPI makes CORS relatively clean via middleware.
pip install fastapi uvicornfrom fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = [
"https://app.example.com",
"https://admin.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
expose_headers=["X-Request-ID"],
max_age=3600,
)
@app.get("/api/health")
def health():
return {"ok": True}Important FastAPI rule#
If allow_credentials=True, do not use allow_origins=["*"].
That combination is invalid for browsers.
CORS in Django#
For Django, django-cors-headers is the standard choice.
pip install django-cors-headerssettings.py#
INSTALLED_APPS = [
"corsheaders",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_EXPOSE_HEADERS = ["X-Request-ID"]
CORS_PREFLIGHT_MAX_AGE = 3600Django session apps#
If you use Django sessions cross-site, you also need cookie and CSRF settings aligned with your deployment. CORS alone is not enough.
Opinionated take: Django plus cross-site session cookies is where many teams drown in config drift. If possible, keep frontend and backend under the same site boundary or use a cleaner token model.
CORS in Spring Boot#
Spring Boot supports CORS both globally and per controller.
Per-controller example#
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
@CrossOrigin(
origins = "https://app.example.com",
allowCredentials = "true",
maxAge = 3600,
exposedHeaders = {"X-Request-ID"}
)
public class UserController {
@GetMapping("/me")
public Map<String, String> me() {
return Map.of("user", "Ava");
}
}Global config#
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com", "https://admin.example.com")
.allowedMethods("GET", "POST", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("X-Request-ID")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}Spring Security caveat#
If you use Spring Security, make sure CORS is integrated there too. A lot of “CORS errors” in Spring apps are actually security filter chain issues or blocked OPTIONS requests.
CORS in Rails#
Rails apps often use the rack-cors gem.
Gemfile#
gem 'rack-cors'config/initializers/cors.rb#
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com', 'https://admin.example.com'
resource '/api/*',
headers: %w[Content-Type Authorization],
methods: [:get, :post, :patch, :put, :delete, :options, :head],
expose: ['X-Request-ID'],
credentials: true,
max_age: 3600
end
endOpinionated Rails note#
Put CORS config near the top of the middleware stack and verify it in staging with real browser traffic. Middleware order matters more than many Rails guides admit.
CORS in Laravel#
Laravel can handle CORS through built-in middleware in modern versions.
config/cors.php#
<?php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => ['https://app.example.com', 'https://admin.example.com'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => ['X-Request-ID'],
'max_age' => 3600,
'supports_credentials' => true,
];Laravel note#
If you use Sanctum or session-based auth across origins, you must line up:
- CORS
- session cookie settings
- CSRF configuration
- frontend credentials mode
Many “Laravel CORS issues” are actually cookie or CSRF issues.
CORS in .NET#
ASP.NET Core has good built-in CORS support.
Program.cs#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendPolicy", policy =>
{
policy.WithOrigins("https://app.example.com", "https://admin.example.com")
.WithMethods("GET", "POST", "PATCH", "DELETE", "OPTIONS")
.WithHeaders("Content-Type", "Authorization")
.WithExposedHeaders("X-Request-ID")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseCors("FrontendPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();Opinionated .NET note#
Middleware order matters. If UseCors is in the wrong place, you can get baffling behavior where some routes work and others fail preflight.
Serverless CORS: Lambda, Vercel, Netlify, Cloudflare Workers#
Serverless platforms make CORS both easier and easier to get subtly wrong.
The recurring issue: your code returns correct headers for GET and POST, but your platform or function does not properly answer OPTIONS.
AWS Lambda behind API Gateway#
Node example:
exports.handler = async (event) => {
const origin = event.headers.origin || event.headers.Origin;
const allowedOrigins = new Set([
"https://app.example.com"
]);
const corsOrigin = allowedOrigins.has(origin) ? origin : null;
if (event.requestContext?.http?.method === "OPTIONS" || event.httpMethod === "OPTIONS") {
return {
statusCode: 204,
headers: {
...(corsOrigin ? { "Access-Control-Allow-Origin": corsOrigin } : {}),
"Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "3600",
"Vary": "Origin"
}
};
}
return {
statusCode: 200,
headers: {
...(corsOrigin ? { "Access-Control-Allow-Origin": corsOrigin } : {}),
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
"Content-Type": "application/json"
},
body: JSON.stringify({ ok: true })
};
};Vercel serverless function#
export default function handler(req, res) {
const allowedOrigins = new Set(["https://app.example.com"]);
const origin = req.headers.origin;
if (allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "3600");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
res.status(200).json({ ok: true });
}Cloudflare Workers#
export default {
async fetch(request) {
const origin = request.headers.get("Origin");
const allowedOrigins = new Set(["https://app.example.com"]);
const corsHeaders = {};
if (origin && allowedOrigins.has(origin)) {
corsHeaders["Access-Control-Allow-Origin"] = origin;
corsHeaders["Access-Control-Allow-Credentials"] = "true";
corsHeaders["Vary"] = "Origin";
}
corsHeaders["Access-Control-Allow-Methods"] = "GET,POST,PATCH,DELETE,OPTIONS";
corsHeaders["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
corsHeaders["Access-Control-Max-Age"] = "3600";
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
...corsHeaders,
"Content-Type": "application/json"
}
});
}
};Serverless opinion#
Always test the deployed edge path, not just local emulation. API gateways, CDN layers, and edge runtimes often mutate or strip headers in ways local dev never reveals.
WebSockets and CORS#
WebSockets are not governed by CORS in the same way fetch and XHR are.
That surprises many developers.
Important distinction#
- Standard CORS headers do not control WebSocket access the same way they control HTTP fetches
- Browsers still send an
Originheader during the WebSocket handshake - Servers should validate
Originexplicitly for browser-initiated WebSocket connections
Example Node WebSocket origin check#
import { WebSocketServer } from "ws";
import http from "http";
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
const allowedOrigins = new Set(["https://app.example.com"]);
server.on("upgrade", (request, socket, head) => {
const origin = request.headers.origin;
if (!allowedOrigins.has(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
});
server.listen(8080);Opinionated WebSocket rule#
Do not assume your normal HTTP CORS middleware protects WebSockets. It usually does not.
GraphQL and CORS#
GraphQL does not change how CORS works, but GraphQL conventions often trigger preflights.
Why?
- GraphQL requests usually use
POST - they often send
Content-Type: application/json - auth is often via
Authorization
That means preflights are common.
Express GraphQL example#
import express from "express";
import cors from "cors";
import { graphqlHTTP } from "express-graphql";
import schema from "./schema.js";
const app = express();
app.use(cors({
origin: "https://app.example.com",
credentials: true,
allowedHeaders: ["Content-Type", "Authorization"],
methods: ["POST", "OPTIONS"],
maxAge: 3600
}));
app.use("/graphql", graphqlHTTP({
schema,
graphiql: false
}));
app.listen(4000);GraphQL gotcha#
Developer tools like GraphiQL, Apollo Sandbox, and hosted explorers may run from different origins. If you want them enabled in non-production environments, you may need separate CORS policies for dev and prod.
Opinionated take: lock GraphQL playground tools down hard in production. They are fantastic for developers and fantastic for attackers doing reconnaissance.
Common CORS errors and how to fix them#
These are the errors teams hit most often.
Error: “No ‘Access-Control-Allow-Origin’ header is present”#
Meaning: the server response lacked a valid Access-Control-Allow-Origin header for that request.
Fix:
- add
Access-Control-Allow-Origin - ensure it matches the requesting origin or use
*only for public non-credentialed endpoints - if dynamic, set
Vary: Origin
Error: “The value of the ‘Access-Control-Allow-Origin’ header must not be ‘*’ when the request’s credentials mode is ‘include’”#
Meaning: you are mixing wildcard origin with credentialed requests.
Fix:
- replace
*with the exact frontend origin - return
Access-Control-Allow-Credentials: true - send credentials from frontend only when needed
Error: “Response to preflight request doesn’t pass access control check”#
Meaning: the OPTIONS response is wrong, missing, blocked, redirected, or malformed.
Fix checklist:
- does the endpoint or gateway answer
OPTIONS? - does it return
Access-Control-Allow-Origin? - does it return
Access-Control-Allow-Methodsincluding the intended method? - does it return
Access-Control-Allow-Headersincluding all requested headers? - is auth middleware blocking
OPTIONS? - is a redirect happening before the preflight completes?
Error: “Request header field authorization is not allowed by Access-Control-Allow-Headers”#
Meaning: browser asked to send Authorization, server did not allow it in preflight.
Fix:
Access-Control-Allow-Headers: Content-Type, AuthorizationError: “CORS policy: Redirect is not allowed for a preflight request”#
Meaning: your OPTIONS request is getting redirected.
Common causes:
- HTTP to HTTPS redirect
- trailing slash redirect
- auth redirect to login page
- CDN rewrite rules
Fix:
- make
OPTIONSresolve directly without redirects - terminate TLS before the app if needed
- avoid login-page redirects for API routes
Error: It works in Postman but not in browser#
Meaning: classic CORS misunderstanding.
Postman is not a browser and does not enforce browser CORS rules.
Fix:
- test from a browser
- inspect network panel
- verify preflight and actual response headers
Error: Cookies are not being sent#
Likely causes:
- frontend forgot
credentials: "include" - server missing
Access-Control-Allow-Credentials: true - origin is wildcard
- cookie missing
SameSite=None; Secure - third-party cookie restrictions or browser privacy features
Fix all layers, not just one.
Error: Response header exists in network tab but JS cannot read it#
Meaning: you forgot Access-Control-Expose-Headers.
Fix:
Access-Control-Expose-Headers: X-Request-ID, Content-DispositionSecurity best practices for CORS#
Here is the opinionated version.
1. Treat CORS as a browser read-permission layer, nothing more#
Do not use CORS as auth. Do not use CORS as authorization. Do not assume disallowed origins cannot hit your API.
They can. They just may not read responses in browser JS.
2. Prefer explicit allowlists#
Good:
https://app.example.comhttps://admin.example.com
Bad:
*for sensitive APIs- regexes you barely understand
- substring matching
If you need multi-tenant custom domains, build careful exact-match validation backed by tenant metadata.
3. Be strict with credentials#
If credentials are involved:
- never use
* - allow only known origins
- set
Vary: Origin - audit cookies and CSRF separately
4. Keep allowed headers minimal#
Do not blindly mirror every requested header.
Allow what your API actually needs:
Content-TypeAuthorization- maybe a few application-specific headers
The less surface area, the easier to reason about.
5. Handle OPTIONS cleanly and early#
Preflights should not:
- hit expensive business logic
- require login
- redirect
- fail due to route mismatches
Return a fast 204 No Content with the right headers.
6. Set Access-Control-Max-Age#
This is both a performance and reliability improvement.
A sane default like 3600 reduces unnecessary preflight noise.
7. Use Vary: Origin when reflecting origins#
If your response changes based on Origin, caches need to know.
Skipping this is a subtle production footgun.
8. Separate public and private endpoints#
Do not use one broad CORS policy for everything.
Better:
- public assets or docs: maybe
* - authenticated API: exact origins only
- admin endpoints: even tighter allowlist
9. Audit CORS at every layer#
Your app may not be the only thing setting headers.
Check:
- app framework
- reverse proxy
- CDN
- API gateway
- WAF
- serverless platform defaults
Conflicting headers are common.
10. Remember CORS does not stop CSRF#
If you use cookies for auth, a malicious site may still be able to trigger state-changing requests even if it cannot read the response.
Use proper CSRF defenses:
- SameSite cookies where possible
- CSRF tokens
- origin/referrer validation for sensitive actions
CORS is not your CSRF strategy.
CORS vs JSONP vs proxy#
These are often mentioned together, but they are very different.
CORS#
Modern browser standard.
Server opts in with headers.
Supports many methods and headers.
Works with fetch and XHR.
Best choice for modern APIs.
JSONP#
Old workaround using <script> tags.
Only supports GET.
Has ugly security and maintainability tradeoffs.
Should be considered legacy.
Example JSONP pattern:
<script src="https://api.example.com/data?callback=handleData"></script>
<script>
function handleData(data) {
console.log(data);
}
</script>Avoid it in 2026 unless you are trapped in an archaeological dig of a codebase.
Proxy#
Your frontend talks to your own backend or dev server, and that server forwards requests to the target API.
Useful when:
- third-party API does not support CORS
- you want to hide secrets
- you want unified auth, caching, or rate limiting
- local development needs same-origin simplicity
Example Express proxy:
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const app = express();
app.use("/third-party", createProxyMiddleware({
target: "https://api.vendor.com",
changeOrigin: true,
pathRewrite: { "^/third-party": "" }
}));
app.listen(3000);Opinionated rule: if a third-party API requires secrets, call it from your backend, not directly from the browser, regardless of CORS.
Case studies#
Case study 1: SPA login broken only in production#
Setup:
- frontend:
https://app.example.com - API:
https://api.example.com - cookie-based sessions
Symptoms:
- login succeeds on server
- browser never stays logged in
- CORS error appears inconsistent
Root causes:
- frontend omitted
credentials: "include" - API returned
Access-Control-Allow-Origin: * - session cookie lacked
SameSite=None; Secure
Fix:
- set
credentials: "include" - return exact origin
- add
Access-Control-Allow-Credentials: true - set cookie correctly
Lesson: cookie auth across origins is a three-layer problem: fetch config, CORS headers, cookie attributes.
Case study 2: Mobile web app slow due to preflight storm#
Setup:
- every API call sent:
AuthorizationContent-Type: application/jsonX-App-VersionX-DeviceX-Locale
- no preflight caching
Symptoms:
- high latency
- doubled request count
- poor performance on mobile networks
Fix:
- remove unnecessary custom headers
- keep only
AuthorizationandContent-Type - set
Access-Control-Max-Age: 3600
Lesson: CORS performance is often an API design problem disguised as a browser problem.
Case study 3: Multi-tenant SaaS with custom domains#
Setup:
- tenants use custom frontend domains
- API reflects origin if it belongs to a registered tenant
Risk:
- sloppy wildcard or suffix matching could allow attacker-controlled domains
Bad logic:
if (origin.endsWith(".customer.com")) allow = true;Why bad:
- public suffix and subdomain ownership assumptions are dangerous
- tenant domain validation often has edge cases
Better:
- exact origin match against verified tenant origins stored in DB
- normalize scheme, host, and port carefully
- set
Vary: Origin
Lesson: dynamic CORS is fine if you do exact matching, not vibes.
Case study 4: “CORS issue” that was actually a 302 redirect#
Setup:
- API gateway redirected
/api/usersto/api/users/ - browser preflight hit the redirect
Symptoms:
- preflight failed
- browser error blamed CORS
Fix:
- stop redirecting
OPTIONS - make canonical API paths consistent
- return direct
204for preflight
Lesson: many CORS errors are routing errors wearing a CORS costume.
Testing CORS properly#
Testing CORS means testing with a browser mindset, not just raw HTTP.
1. Browser DevTools#
Open the Network tab. Inspect:
- preflight
OPTIONS - actual request
- request headers:
OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
- response headers:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-Control-Max-Age
This is your source of truth.
2. curl#
curl does not enforce CORS, but it helps inspect server behavior.
Preflight simulation:
curl -i -X OPTIONS https://api.example.com/users/123 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PATCH" \
-H "Access-Control-Request-Headers: content-type,authorization"Actual request simulation:
curl -i https://api.example.com/users/123 \
-H "Origin: https://app.example.com"3. Automated integration tests#
Write tests that verify headers on both preflight and actual responses.
Example with Node and supertest:
import request from "supertest";
import app from "./app.js";
test("preflight allows PATCH with Authorization", async () => {
const res = await request(app)
.options("/api/users/123")
.set("Origin", "https://app.example.com")
.set("Access-Control-Request-Method", "PATCH")
.set("Access-Control-Request-Headers", "content-type,authorization");
expect(res.status).toBe(204);
expect(res.headers["access-control-allow-origin"]).toBe("https://app.example.com");
expect(res.headers["access-control-allow-methods"]).toContain("PATCH");
expect(res.headers["access-control-allow-headers"]).toMatch(/Authorization/i);
});4. Use dedicated header inspection tools#
Tools like headertest.com are useful for checking whether your deployed endpoints return the headers you think they return. They are especially handy when a CDN, proxy, or edge platform may be modifying responses.
5. Test staging behind the real edge stack#
Local success means very little if production traffic goes through:
- CloudFront
- Fastly
- Nginx
- API Gateway
- Vercel edge
- Cloudflare
- ingress controllers
Always test the deployed path.
Browser compatibility in 2026#
CORS is broadly supported across all modern browsers. The real compatibility issues in 2026 are rarely about whether CORS exists. They are about browser privacy behavior, cookie restrictions, and subtle implementation details.
What is stable#
- core CORS headers
- preflight flow
- credential restrictions with wildcard origins
- exposed header behavior
What still varies in practice#
- maximum honored
Access-Control-Max-Age - cookie behavior in cross-site contexts
- privacy features that affect third-party cookies and storage
- enterprise browser oddities and embedded webviews
Practical advice#
If your app depends on cross-site cookies, test on:
- Chrome
- Firefox
- Safari
- mobile webviews if relevant
Safari and embedded browsers are often where “but CORS is configured correctly” turns into a long afternoon.
Edge cases that trip up experienced teams#
null origin#
Some requests may have Origin: null, such as from:
- sandboxed iframes
- local files
- some privacy-restricted contexts
Do not casually allow null unless you truly understand why you need it.
Multiple Access-Control-Allow-Origin headers#
Invalid and broken in many cases.
Return one valid value, not a comma-separated list and not duplicate headers.
Bad:
Access-Control-Allow-Origin: https://app.example.com, https://admin.example.comCORS does not support listing multiple origins in one header value.
If you need multiple allowed origins, dynamically return the matching one.
204 vs 200 for preflight#
Either can work, but 204 No Content is clean and conventional for preflight responses.
Use it unless your framework makes it awkward.
Missing CORS on error responses#
A classic production bug:
- success responses include CORS headers
- 401, 403, 500 responses do not
Result: frontend sees mysterious CORS failures instead of useful API errors.
Make sure CORS headers are applied consistently, including error paths.
CDN caching without Vary: Origin#
Already mentioned, worth repeating.
If origin-specific responses are cached without Vary: Origin, you will get random, hard-to-reproduce failures.
Fonts and static assets#
Fonts often need CORS when loaded cross-origin by browsers.
Example for a font asset:
Access-Control-Allow-Origin: *For truly public static assets, wildcard is usually fine.
Service workers#
Service workers do not magically bypass CORS. They still operate within browser security rules. If your app architecture relies on service workers fetching cross-origin APIs, CORS still matters.
Private Network Access#
Some browser contexts may enforce extra checks when public sites try to access private network resources. This is adjacent to CORS and can surface as confusing preflight-like failures in local or enterprise environments.
A sane default CORS policy for most APIs#
If I had to recommend a default for a typical production API used by a known frontend, it would look like this:
- exact allowlist of frontend origins
- allow credentials only if truly needed
- allow only required methods
- allow only required headers
- expose only useful response headers
- set
Access-Control-Max-Age: 3600 - set
Vary: Origin - return CORS headers on error responses too
- answer
OPTIONSfast without auth redirects
Example generic response policy:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID
Vary: OriginPreflight response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
Vary: OriginThat is boring, and boring is good.
What developers should stop doing in 2026#
Stop setting * everywhere#
It is lazy, often wrong, and usually copied from a Stack Overflow answer written under duress.
Stop calling every browser network failure a CORS issue#
Sometimes it is:
- DNS
- TLS
- redirect loops
- blocked
OPTIONS - cookie policy
- auth middleware
- proxy misconfiguration
Stop relying on framework defaults without checking real headers#
Defaults change. Middleware order changes. Edge platforms interfere.
Stop exposing more headers and methods than necessary#
Least privilege applies to CORS too.
Stop assuming WebSockets are covered by HTTP CORS config#
They are not.
Final practical checklist#
When a cross-origin browser request fails, check this in order:
- Is the frontend origin exactly what you expect?
- Does the response include the correct
Access-Control-Allow-Origin? - If credentials are used:
- is origin explicit, not
*? - is
Access-Control-Allow-Credentials: truepresent? - is frontend using
credentials: "include"? - are cookies using
SameSite=None; Secure?
- is origin explicit, not
- If preflighted:
- does
OPTIONSreturn 200/204? - does it allow the intended method?
- does it allow all requested headers?
- is it being redirected or blocked by auth?
- does
- Are custom response headers exposed with
Access-Control-Expose-Headers? - If origin is dynamic, is
Vary: Originset? - Are error responses also carrying CORS headers?
- Is a CDN, gateway, or proxy altering headers?
- Does it fail in browser only, while
curlworks? If yes, that is entirely consistent with a CORS problem. - Verify with browser DevTools and a header inspection tool like headertest.com.
The blunt takeaway#
CORS is not hard because the spec is magical. It is hard because it sits at the intersection of:
- browser security rules
- HTTP semantics
- framework middleware
- cookies
- proxies
- CDNs
- auth
- developer assumptions
The winning approach is simple:
- understand the browser flow
- keep policy explicit
- avoid wildcards on sensitive APIs
- handle preflight intentionally
- test the deployed path, not just local code
- never confuse CORS with real access control
If you do that, CORS becomes routine instead of maddening. And that is exactly where it belongs.