You’ve fetched data from your own API in development. It works. You deploy it to production where the API is on a different subdomain. It breaks with Access-Control-Allow-Origin errors. You spend two hours adding random headers until something works. You’ve just been bitten by CORS.
CORS (Cross-Origin Resource Sharing) is the browser security model that controls when JavaScript on one origin can make requests to another origin. It’s misunderstood, often misconfigured, and the source of disproportionate developer frustration. This post explains what CORS actually is, what problems it solves, the request flow, and the configuration patterns that work in production.
The Same-Origin Policy
CORS is the relaxation of an older browser policy: the Same-Origin Policy (SOP).
By default, JavaScript running on https://example.com cannot read responses from https://api.example.com (or https://anything-else.com). This is the SOP — origins are isolated from each other.
“Same origin” means the same scheme + host + port:
https://example.comandhttp://example.com— different origins (scheme).https://example.comandhttps://api.example.com— different origins (host).https://example.comandhttps://example.com:8080— different origins (port).
The SOP exists because of a real attack: a malicious site could otherwise have your browser make authenticated requests to your bank, your email, your admin panel, and read the responses. SOP blocks this by default.
Why CORS Exists
The SOP is too restrictive for the modern web. Legitimate apps regularly need to request data from a different origin: the frontend on app.example.com calling an API on api.example.com, or a JavaScript SDK calling analytics.thirdparty.com.
CORS is the mechanism that lets servers say “I’m OK being called by JavaScript from origin X.” The browser checks the server’s response headers and decides whether to expose the response to the JavaScript.
The key insight: CORS is enforced by the browser, not the server. The server can return whatever it wants; the browser decides whether the JavaScript can read it. This is why CORS errors look weird — the request succeeded, but the JS got an error.
Simple Requests
The simplest CORS flow:
- JavaScript makes a request to
https://api.example.com/datafrom a page onhttps://app.example.com. - Browser includes the
Originheader:Origin: https://app.example.com. - Server responds normally but adds:
Access-Control-Allow-Origin: https://app.example.com(or*for any origin). - Browser sees the matching header and exposes the response to JavaScript.
If the server doesn’t include the Access-Control-Allow-Origin header (or it doesn’t match the origin), the response is blocked from JavaScript. The fetch resolves but response is essentially opaque.
Simple requests are limited to:
- Methods: GET, HEAD, POST.
- Headers: a small allowlist (
Accept,Content-Typewith limited values, etc.). - No custom headers like
Authorization.
If the request goes beyond these constraints, you’re in preflighted territory.
Preflighted Requests
When the request can’t be simple (custom headers, non-GET/POST method, etc.), the browser sends an OPTIONS request first to check whether the server permits the real request.
OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
The server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
If the response allows the method and headers, the browser then sends the actual request. If it doesn’t, the actual request is never sent and JavaScript gets a CORS error.
The Access-Control-Max-Age lets browsers cache the preflight result (so they don’t ask before every request). Setting it appropriately (a few hours minimum) eliminates redundant OPTIONS round trips.
The Headers
A quick reference:
Sent by the browser (on the request)
Origin: https://app.example.com— Where the request is coming from.Access-Control-Request-Method: PUT— (preflight) What method the real request will use.Access-Control-Request-Headers: Authorization— (preflight) What headers the real request will send.
Sent by the server (on the response)
Access-Control-Allow-Origin: https://app.example.com— Which origin is allowed (or*for any).Access-Control-Allow-Methods: GET, POST, PUT, DELETE— Which methods are allowed.Access-Control-Allow-Headers: Authorization, Content-Type— Which request headers are allowed.Access-Control-Allow-Credentials: true— Whether cookies / auth can be included.Access-Control-Expose-Headers: X-Custom-Header— Which response headers JavaScript can read (defaults: a small set).Access-Control-Max-Age: 86400— How long browsers can cache the preflight response.
Credentials and the * Trap
The Access-Control-Allow-Origin: * is the most permissive setting — any origin can call the API. But it doesn’t work with credentials.
If you want JavaScript to send cookies or auth headers cross-origin:
- Frontend must set
credentials: 'include'on the fetch. - Server must set
Access-Control-Allow-Originto a specific origin (not*). - Server must set
Access-Control-Allow-Credentials: true.
// Frontend
const response = await fetch('https://api.example.com/me', {
credentials: 'include'
})
// Server response headers
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com')
res.setHeader('Access-Control-Allow-Credentials', 'true')
This is the most common CORS mistake: trying to use Allow-Origin: * with credentials and getting confused why cookies aren’t sent.
Common Mistakes
Setting Allow-Origin: * with credentials
The browser will block the request. Always set a specific origin with credentials.
Not handling OPTIONS
Your routes handle GET and POST but not OPTIONS. Preflight fails with 404 or 405. Add a generic OPTIONS handler that returns the right CORS headers.
Returning CORS headers only on success
Your error pages return 500 without CORS headers. The browser sees no Access-Control-Allow-Origin and blocks the error response. JavaScript gets a generic CORS error instead of the actual error message.
Always include CORS headers on every response, including errors. Otherwise debugging breaks.
Origin allowlist that’s too narrow
Subdomain mismatch (https://www.example.com vs https://example.com). Use a function or regex to validate origins.
Wildcard subdomain handling
Browsers don’t support wildcard subdomains like https://*.example.com in Access-Control-Allow-Origin. You have to echo back the matched origin:
const allowedOrigins = /\.example\.com$/
const origin = req.headers.origin
if (allowedOrigins.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Vary', 'Origin') // Important for caching!
The Vary: Origin is essential — otherwise CDNs cache the response with one origin’s header and serve it to other origins.
Not setting Vary: Origin
The response is cached at CDN with origin A’s Allow-Origin header. A user from origin B requests; gets origin A’s headers; browser blocks.
Vary: Origin makes the cache key include the Origin header.
Believing CORS is for security
CORS is not a server-side security mechanism. It’s a browser-side relaxation of the SOP. Server-to-server requests, CLI tools, and non-browser clients ignore CORS entirely.
If your server should reject requests from certain origins, validate at the server level (auth, API keys, IP allowlist) — not via CORS.
CORS with a Reverse Proxy
A common pattern: put the API and the frontend on the same origin via a reverse proxy, so CORS isn’t needed at all.
https://example.com/ → frontend (static)
https://example.com/api/* → backend
Both routes share the same origin. No CORS. No preflight. Simpler.
This is the cleanest approach when feasible. CDNs like Cloudflare make this easy via Workers; nginx/Caddy can do it natively.
CORS and Cloud Storage
S3, GCS, Azure Blob Storage all have CORS configuration. When you serve assets directly from a storage bucket and want JavaScript to read them (e.g., for canvas processing or font loading), you must configure CORS at the bucket level.
{
"CORSRules": [{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}]
}
If you serve through a CDN in front of the bucket, configure CORS at the CDN instead.
CORS in Common Frameworks
Express
import cors from 'cors'
app.use(cors({ origin: 'https://app.example.com', credentials: true }))
FastAPI
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Laravel
Configure config/cors.php:
'paths' => ['api/*'],
'allowed_origins' => ['https://app.example.com'],
'allowed_methods' => ['*'],
'supports_credentials' => true,
Go (gorilla/handlers)
import "github.com/gorilla/handlers"
http.ListenAndServe(":8080", handlers.CORS(
handlers.AllowedOrigins([]string{"https://app.example.com"}),
handlers.AllowCredentials(),
)(router))
Debugging CORS
Steps that work:
- Open DevTools → Network. Find the failing request.
- Check if a preflight (OPTIONS) was sent. If yes, look at its response — is
Access-Control-Allow-Originpresent? Does it match? - Check Console. CORS errors include specific details about which header was missing or didn’t match.
- Test with
curlto verify the server is sending the headers you expect. CORS issues only manifest in browsers;curlignores CORS. - Use a CORS test tool like
test-cors.orgif you want to verify a public endpoint.
The trap: CORS errors in DevTools say something vague like “Access blocked by CORS policy.” The actual fix is almost always at the server: add or fix a header.
TL;DR
- CORS controls cross-origin JavaScript requests in the browser.
- Browsers enforce it, not servers. Server returns headers; browser decides.
- Simple requests (GET/POST, basic headers) require only
Access-Control-Allow-Origin. - Preflighted requests require an OPTIONS response with explicit Method/Header allowlists.
- Credentials require a specific origin, not
*. - Always include CORS headers on errors, not just success.
- Set
Vary: Originwhen allowlisting multiple origins. - CORS is not security. Validate server-side; CORS is browser UX policy.
- Same-origin via reverse proxy eliminates CORS entirely.
CORS is one of those topics where the documentation is correct but somehow nobody seems to read it. Once you understand the simple model (browser enforces; server signals permission), the headers stop being magic. For related security-context topics, see TLS handshake; for application-level patterns, IP geolocation in Node.js.