CORS Explained: Why It Happens & How to Fix It
It is Friday afternoon. Your frontend works perfectly in Postman. You ship to staging, open the browser, and get this in the console:
Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.The API is up. The network tab shows the request going out and a 200 response coming back. Yet JavaScript cannot read it. This is Cross-Origin Resource Sharing (CORS) — a browser security mechanism that over 60% of web developers encounter at some point in their careers, according to data aggregated from Stack Overflow questions and developer surveys.
This guide explains what CORS actually is, why the browser enforces it even when the server responds successfully, how preflight requests work, and exactly what headers to add to every major backend framework.
Key Takeaways
- • CORS is browser-enforced, not server-enforced. curl and Postman never see CORS errors — they skip the browser's check entirely.
- • The same-origin policy is the root cause. CORS is the relaxation mechanism that lets servers selectively allow cross-origin access.
- • Preflight (OPTIONS) requests must return 200 or 204. A 404 or 405 for OPTIONS kills the actual request before it is ever sent.
- • Wildcard + credentials = always rejected.
Access-Control-Allow-Origin: *cannot be combined withAccess-Control-Allow-Credentials: true. - • The fix lives on the server, not the client. Disabling browser security or using CORS proxies is a workaround that introduces security holes.
The Same-Origin Policy: Why CORS Exists
The same-origin policy (SOP) is a foundational browser security control defined in RFC 6454. It prevents JavaScript on one origin from reading data from a different origin. An origin is the combination of scheme + host + port. All three must match exactly.
# Origin: https://app.example.com
# Same origin ✓
https://app.example.com/api/users
https://app.example.com/dashboard
# Cross-origin ✗ — different scheme
http://app.example.com/api # http vs https
# Cross-origin ✗ — different host
https://api.example.com/users # api. subdomain differs
https://example.com/api # different host entirely
# Cross-origin ✗ — different port
https://app.example.com:8080/api # explicit port 8080 vs 443
# The policy exists to prevent this attack scenario:
# 1. User logs into bank.com (session cookie set)
# 2. User visits evil.com
# 3. evil.com JavaScript: fetch('https://bank.com/api/balance')
# 4. Without SOP: browser sends session cookie, returns balance to evil.com
# 5. With SOP: browser blocks evil.com from reading the responseCORS is not a restriction — it is an extension to SOP that lets server operators explicitly opt in to cross-origin access. Without CORS headers, the browser assumes the server does not consent to cross-origin reads and blocks JavaScript from seeing the response (even though the request was sent and the server responded).
Simple Requests vs Preflighted Requests
Not all cross-origin requests trigger a preflight. The Fetch specification defines a class of "simple" requests that can be sent directly, because they are equivalent to what an HTML form or <img> tag could already do before CORS existed.
| Criterion | Simple (no preflight) | Non-simple (preflight triggered) |
|---|---|---|
| HTTP method | GET, HEAD, POST | PUT, DELETE, PATCH, OPTIONS |
| Content-Type (POST) | application/x-www-form-urlencoded, multipart/form-data, text/plain | application/json, application/xml, any other type |
| Custom headers | None (only CORS-safelisted headers) | Any custom header (Authorization, X-API-Key, etc.) |
| Real-world prevalence | Rare in modern APIs | Almost all REST/GraphQL API calls |
In practice, nearly every modern API call triggers a preflight because it either uses Content-Type: application/json or sends an Authorization header. If you are working with REST APIs or GraphQL, assume preflights are happening.
How a Preflight Works
# Step 1: Browser sends OPTIONS preflight
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
# Step 2: Server must respond with 200 or 204
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 # Cache preflight for 24 hours
Vary: Origin
# Step 3: If preflight succeeds, browser sends actual request
POST /api/users HTTP/1.1
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGci...
# Step 4: Server responds with CORS headers on actual response too
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"id": 42, "name": "Alice"}
# Step 5: Browser checks headers, exposes response to JavaScriptAccess-Control-Max-Age controls how long the browser caches the preflight result. Chrome caps this at 7200 seconds (2 hours); Firefox allows up to 86400 seconds (24 hours). Setting it avoids a preflight on every single request during a session — a meaningful performance difference if your API serves many endpoints.
CORS Headers Reference
| Header | Direction | What It Does |
|---|---|---|
| Access-Control-Allow-Origin | Response | Which origin can read the response. Use exact origin or * |
| Access-Control-Allow-Methods | Preflight response | HTTP methods the server permits for cross-origin requests |
| Access-Control-Allow-Headers | Preflight response | Headers the client is allowed to send (must list all custom headers) |
| Access-Control-Allow-Credentials | Response | Allow cookies and auth headers. Requires exact origin, not * |
| Access-Control-Max-Age | Preflight response | Seconds to cache the preflight result (reduces OPTIONS round trips) |
| Access-Control-Expose-Headers | Response | Custom headers JavaScript is allowed to read from the response |
| Origin | Request | Set by browser automatically — the origin making the request |
| Vary: Origin | Response | Tells CDNs/caches that the response differs per origin (critical with allowlists) |
Fixing CORS: Server-Side Implementations
The fix always lives on the server — the browser cannot be told to ignore CORS from client-side code. Here is the correct configuration for the most common backend stacks.
Node.js / Express
// npm install cors
const express = require('express')
const cors = require('cors')
const app = express()
// Option 1: Allow all origins (development only — NOT production)
app.use(cors())
// Option 2: Allow specific origin(s)
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies + auth headers
maxAge: 86400, // Cache preflight for 24 hours
}))
// Option 3: Dynamic origin allowlist (multi-tenant apps)
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
]
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (curl, Postman, server-to-server)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`))
}
},
credentials: true,
}))
// CRITICAL: Handle preflight for all routes
app.options('*', cors())
// Error handler must still set CORS headers
app.use((err, req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*')
res.status(500).json({ error: err.message })
})Python / FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Add CORS middleware — must be added BEFORE routes
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://app.example.com",
"https://admin.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
max_age=86400,
expose_headers=["X-Request-ID"], # Headers JS can read from response
)
# Development: allow all origins
# allow_origins=["*"]
# Note: credentials=True requires specific origins, never ["*"]Python / Django
# pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
...
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Must be first
'django.middleware.common.CommonMiddleware',
...
]
# Specific origins (production)
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
# Or pattern-based
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://\w+\.example\.com$",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
"content-type",
"authorization",
"x-request-id",
]
CORS_PREFLIGHT_MAX_AGE = 86400
# Development only: allow all
# CORS_ALLOW_ALL_ORIGINS = TrueNginx (Reverse Proxy)
# nginx.conf — add CORS at the proxy level
# Useful when you cannot modify the backend (legacy app, microservice)
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
# Handle preflight
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' '86400' always;
add_header 'Content-Length' '0';
return 204;
}
# Add CORS headers to all non-preflight responses
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://backend:3000;
}
}
# IMPORTANT: "$http_origin" echoes back the request origin.
# Only use this with an application-level allowlist check,
# or restrict to known IP ranges — never in public APIs.Cloudflare Workers
// Cloudflare Worker — intercept and add CORS headers
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
])
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin')
const isAllowed = ALLOWED_ORIGINS.has(origin)
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
},
})
}
// Forward to origin
const response = await fetch(request)
const newResponse = new Response(response.body, response)
if (isAllowed) {
newResponse.headers.set('Access-Control-Allow-Origin', origin)
newResponse.headers.set('Access-Control-Allow-Credentials', 'true')
newResponse.headers.set('Vary', 'Origin')
}
return newResponse
},
}Common CORS Mistakes (and Exact Fixes)
# Mistake 1: Wildcard + credentials
# ✗ Browser rejects this combination
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# ✓ Fix: specify exact origin
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
# ---
# Mistake 2: Missing Vary: Origin header
# Without it, CDNs and reverse proxies cache a response for one origin
# and serve it to all others — stripping or wrong-origin the header
# ✓ Always add:
Vary: Origin
# ---
# Mistake 3: OPTIONS returns 404 or 405
# Express default: no route defined for OPTIONS → 404
# ✓ Fix: explicitly handle OPTIONS
app.options('*', cors()) # Express
# or return 204 manually in your router
# ---
# Mistake 4: Header set multiple times
# Some middleware stacks apply the header twice:
# Access-Control-Allow-Origin: https://app.example.com, https://app.example.com
# Browsers reject duplicate values for this header
# ✓ Fix: ensure only one middleware handles CORS, check middleware order
# ---
# Mistake 5: Error handler fires before CORS middleware
# An uncaught exception early in the chain can skip CORS middleware
# ✓ Fix: register CORS middleware first, before any route or error handler
# In Express:
app.use(cors()) // ← First
app.use(router) // ← After
# ---
# Mistake 6: Exact origin mismatch (trailing slash)
# Request Origin: https://app.example.com (no trailing slash)
# Header value: https://app.example.com/ (trailing slash)
# ✗ These are different strings — browser rejects
# ✓ Always store and compare origins without trailing slashesCORS in Development: The Proxy Approach
During local development, if your frontend runs on localhost:5173 and your API on localhost:3000, that is a cross-origin request (different port = different origin). A dev proxy makes the browser think everything is on the same origin.
// vite.config.ts — proxy /api/* to backend
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
// Browser sees: fetch('/api/users') → same origin ✓
// Vite forwards: http://localhost:3000/users → no CORS involved
// package.json (Create React App) — simpler proxy
{
"proxy": "http://localhost:3000"
}
// Next.js rewrites (next.config.ts)
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:3000/:path*',
},
]
},
}Understanding CORS errors requires knowing what status codes the server is actually returning. The HTTP Status Codes guide covers the codes your preflight should return (204, 200) vs what causes failures (404, 405, 500). For API authentication alongside CORS, see the API Authentication Methods guide.
CORS and Security: What It Does and Does Not Protect
CORS is frequently misunderstood as a security measure you configure on your API. It is not — it is a browser contract. Misunderstanding this causes developers to either over-restrict (blocking legitimate clients) or under-restrict (false sense of security).
# CORS does NOT:
# ✗ Block server-to-server requests (curl, Postman, Python requests)
# ✗ Prevent cross-origin request from being sent (it IS sent)
# ✗ Protect your API from unauthorized access on its own
# ✗ Replace authentication (JWTs, API keys, sessions)
# ✗ Apply to WebSocket connections (different security model)
# ✗ Apply to server-side rendered pages (SSR fetches server-to-server)
# CORS DOES:
# ✓ Prevent malicious websites from reading your API responses via browser JS
# ✓ Block credentials from being sent cross-origin by default
# ✓ Require explicit server consent before a browser exposes responses
# The correct security stack:
# 1. Authentication — who are you? (JWT, API key, session)
# 2. Authorization — what can you do? (RBAC, scopes)
# 3. CORS — which origins can make browser-based requests?
# 4. Rate limiting — how many requests?
# 5. HTTPS — encrypted transport
# Real attack CORS prevents:
# evil.com loads in browser → JS runs → fetch("https://bank.com/balance")
# Without CORS: browser sends session cookie → bank responds → evil.com reads it
# With CORS: browser sends request → bank responds → browser BLOCKS evil.com from reading
# Attack CORS does NOT prevent:
# Server at evil-host.com → HTTP POST to bank.com/transfer (no browser involved)
# → Requires authentication to stop, not CORSIf your API uses JWT tokens for authentication, CORS and JWTs are complementary: CORS controls which browser origins can make requests; the JWT controls what the authenticated user is allowed to do. You need both.
WebSockets and CORS
WebSocket connections are not governed by CORS. The initial HTTP upgrade request carries an Origin header, but the browser does not enforce CORS on WebSocket upgrades — it sends the request regardless of origin and does not check for Access-Control-Allow-* headers in the response.
// WebSocket — browser sends Origin but does NOT enforce CORS
const ws = new WebSocket('wss://api.example.com/ws')
// No CORS preflight. No CORS check on the 101 response.
// Your WebSocket server MUST validate Origin manually:
// Node.js / ws library
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 8080 })
const ALLOWED_ORIGINS = ['https://app.example.com']
wss.on('connection', (socket, request) => {
const origin = request.headers.origin
if (!ALLOWED_ORIGINS.includes(origin)) {
socket.close(4001, 'Origin not allowed')
return
}
// Proceed with authenticated connection
})
// Python / websockets library
import websockets
async def handler(websocket, path):
origin = websocket.request_headers.get('Origin')
if origin not in ALLOWED_ORIGINS:
await websocket.close(4001, 'Origin not allowed')
return
# ...For more on WebSocket architecture and security, see the WebSockets Guide.
Debugging CORS Errors Systematically
# Step 1: Open DevTools → Network tab → find the failing request
# Check: Is there an OPTIONS request before it? Did OPTIONS get 200/204?
# If OPTIONS returned 404/405 → your server doesn't handle OPTIONS
# Step 2: Check the OPTIONS response headers
curl -v -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
# Expected response headers:
# access-control-allow-origin: https://app.example.com
# access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
# access-control-allow-headers: content-type, authorization
# access-control-max-age: 86400
# Step 3: Check actual request response (not just preflight)
curl -v -X POST https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"name": "test"}'
# The actual response also needs Access-Control-Allow-Origin
# Step 4: Check for duplicate headers
curl -sI -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" | grep -i access-control
# If you see a header twice, your middleware is duplicating it
# Step 5: Verify origin string matches exactly
# Request Origin: https://app.example.com
# Response header: https://app.example.com ← must be identical stringFrequently Asked Questions
Why does CORS only block requests in the browser?
CORS is enforced by browsers, not servers. curl and Postman send the same request and receive the same server response — they just skip the CORS check. The browser is a shared execution environment where malicious scripts from any domain could run; curl is controlled by the developer running it.
What is a CORS preflight request?
A preflight is an OPTIONS request the browser sends before a non-simple request to ask the server: do you allow this method and these headers from this origin? The server must respond with Access-Control-Allow-* headers and a 200 or 204 status. If the server fails, the actual request is never sent.
Can I use Access-Control-Allow-Origin: * with credentials?
No. The browser rejects this combination unconditionally. If your request includes credentials (cookies, Authorization headers), you must set Access-Control-Allow-Credentials: true AND specify the exact origin — the wildcard is not allowed. This is defined in the Fetch specification.
What is a "simple" CORS request that does not trigger a preflight?
Simple requests use GET, HEAD, or POST with Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain — and no custom headers. Most modern API calls (JSON POST with Authorization) are non-simple and trigger a preflight. The simple category exists for historical HTML form compatibility.
Why does CORS error occur even when the server sends the right headers?
Common causes: OPTIONS returns wrong status (must be 200/204), Access-Control-Allow-Origin value differs from request Origin (even a trailing slash matters), the header is set twice by stacked middleware, or an error handler fires before CORS middleware adds headers.
How do I fix CORS in development without changing the server?
Use a development proxy. In Vite: server.proxy in vite.config.ts to forward /api/* to your backend. In CRA: add "proxy" to package.json. The proxy makes requests appear same-origin to the browser. Never use browser extensions that disable CORS checks — they mask real production bugs.
Does CORS apply to WebSocket connections?
No. WebSocket connections bypass CORS. The browser sends the Origin header but does not enforce any CORS check on the upgrade response. Always validate the Origin header server-side before accepting WebSocket connections — it is your sole defense at the protocol level.
Debug CORS and API Responses
Inspect your API's CORS response headers and format the JSON payloads with BytePane's JSON Formatter. For reference on every HTTP status code your preflight might return, see the HTTP Status Codes Guide. Working on REST API design alongside CORS? See the REST API Best Practices guide.
Open JSON FormatterRelated Articles
HTTP Status Codes Guide
What 200, 204, 404, 405 mean for CORS preflight responses.
API Authentication Methods
JWT, API keys, OAuth — authentication works alongside CORS.
REST API Best Practices
Design patterns for APIs that interact well with browser clients.
WebSockets Guide
How WebSockets bypass CORS and what origin security means for WS.