You've seen the error. Red text in the console: "Access to fetch at ... has been blocked by CORS policy." You panic. You google. You add Access-Control-Allow-Origin: * to your server and move on. But you never really understood why it happened or what CORS actually does.
That's fine — most developers go through this. CORS is one of those things that blocks you at the worst possible time (usually during a demo) and feels like the browser is fighting against you. But once you understand the mechanism, it clicks and you never get stuck on it again.
This guide walks through everything: what CORS is, why it exists, how the browser enforces it, what preflight requests are, every CORS header explained, and how to fix the most common errors you'll hit in real projects.
What is CORS?
CORS stands for Cross-Origin Resource Sharing. It's a mechanism built into browsers that controls whether JavaScript running on one origin (e.g., https://myapp.com) is allowed to read responses from a different origin (e.g., https://api.myapp.com).
An "origin" is the combination of protocol + domain + port. These are all different origins:
https://myapp.com ← Origin A https://api.myapp.com ← Origin B (different subdomain) http://myapp.com ← Origin C (different protocol) https://myapp.com:3000 ← Origin D (different port) https://myapp.com ← Same as Origin A ✓
When your frontend on Origin A makes a fetch request to Origin B, the browser says: "hold on, that's a cross-origin request. Let me check if Origin B allows this." It looks at the response headers from Origin B to decide.
Key point: CORS is enforced by the browser, not the server. The server just sends headers that tell the browser what's allowed. If you call your API from Postman, curl, or another server — CORS never applies. It's purely a browser security feature.
Why CORS Exists: The Same-Origin Policy
To understand CORS, you need to understand the problem it solves. Browsers have a rule called the Same-Origin Policy (SOP) that's been around since the mid-90s. It says:
"JavaScript on one origin cannot read responses from a different origin."
Why? Imagine there's no Same-Origin Policy. You visit evil.com, and it runs JavaScript that:
- Fetches
https://yourbank.com/account/balance— using your logged-in cookies - Reads the response (your bank balance, transactions, everything)
- Sends that data to the attacker's server
The Same-Origin Policy prevents this. Your browser will send the request (with cookies), but it won't let evil.com's JavaScript read the response. The data stays protected.
But here's the problem: legitimate cross-origin requests are everywhere. Your frontend on app.example.com needs to call your API on api.example.com. A React app on localhost:3000 needs to hit your Express server on localhost:8080.
That's where CORS comes in. It's an exception system that lets servers explicitly opt in to cross-origin access. The server says "I trust requests from these specific origins" by including CORS headers in its response.
How CORS Works: Simple vs Preflight Requests
Not all cross-origin requests are handled the same way. The browser divides them into two categories: simple requests and preflight requests.
Simple Requests
A request is "simple" when it meets ALL of these conditions:
- Method is
GET,HEAD, orPOST - Only uses "safe" headers: Accept, Accept-Language, Content-Language, Content-Type
- Content-Type (if set) is one of:
application/x-www-form-urlencoded,multipart/form-data, ortext/plain
For simple requests, the browser sends the request directly and then checks the response headers to decide whether the JavaScript is allowed to read the response.
Preflight Requests (Non-Simple)
Everything else triggers a preflight. That means:
- Methods:
PUT,DELETE,PATCH - Custom headers like
Authorization,X-Custom-Header - Content-Type of
application/json(yes, this triggers preflight!)
Before sending the actual request, the browser first sends an OPTIONS request asking: "Hey server, are you cool with a PUT request from this origin, with these headers?" Only if the server responds with the right CORS headers does the browser proceed with the actual request.
CORS Request Flow
Here's what happens step by step for both simple and preflight requests:
Simple Request Flow
Browser (https://myapp.com) Server (https://api.myapp.com)
│ │
│ GET /users │
│ Origin: https://myapp.com │
│ ────────────────────────────────────────► │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: https://myapp.com
│ ◄──────────────────────────────────────── │
│ │
│ ✓ Origin matches → JS can read response │
│ │Preflight Request Flow
Browser (https://myapp.com) Server (https://api.myapp.com)
│ │
│ OPTIONS /users │ ← Preflight
│ Origin: https://myapp.com │
│ Access-Control-Request-Method: DELETE │
│ Access-Control-Request-Headers: Authorization
│ ────────────────────────────────────────► │
│ │
│ 204 No Content │ ← Server approves
│ Access-Control-Allow-Origin: https://myapp.com
│ Access-Control-Allow-Methods: GET, POST, DELETE
│ Access-Control-Allow-Headers: Authorization
│ Access-Control-Max-Age: 86400 │
│ ◄──────────────────────────────────────── │
│ │
│ DELETE /users/123 │ ← Actual request
│ Origin: https://myapp.com │
│ Authorization: Bearer token123 │
│ ────────────────────────────────────────► │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: https://myapp.com
│ ◄──────────────────────────────────────── │
│ │Notice: the preflight is invisible to your JavaScript code. You just write fetch()and the browser handles the OPTIONS request behind the scenes. But you'll see it in the Network tab — and if it fails, your actual request never fires.
CORS Headers Explained
Six response headers control CORS behavior. Here's each one broken down — what it does, why you need it, where to set it, and who sends it.
• Access-Control-Allow-Origin
What: Tells the browser which origins are allowed to read the response. This is the primary CORS header — without it, cross-origin requests are always blocked.
Why: This is the gate. If the requesting origin doesn't match this header, the browser blocks JavaScript from accessing the response body, headers, or status.
Where: Every cross-origin response. Set on the server — either in your application code (Express middleware, Next.js headers) or reverse proxy (Nginx, Cloudflare).
Set by: Server (response header)
Access-Control-Allow-Origin: https://myapp.com ← Allow specific origin
Access-Control-Allow-Origin: * ← Allow any origin (public APIs)
// ⚠️ You CANNOT list multiple origins directly:
Access-Control-Allow-Origin: https://a.com, https://b.com ← INVALID
// Instead, dynamically set based on request Origin header:
// if (allowedOrigins.includes(req.headers.origin)) {
// res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
// }• Access-Control-Allow-Methods
What: Lists which HTTP methods are allowed for cross-origin requests. Sent in the preflight response.
Why: Even if the origin is allowed, the browser checks whether the specific method (DELETE, PUT, PATCH) is permitted. Simple methods (GET, POST, HEAD) don't need this — but it's good practice to include them.
Where: Preflight (OPTIONS) responses. Required when your API uses anything beyond GET/POST.
Set by: Server (preflight response)
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
• Access-Control-Allow-Headers
What: Lists which request headers the client is allowed to send in cross-origin requests.
Why: Custom headers like Authorization, X-Request-ID, or Content-Type: application/json require explicit permission. Without this, the preflight fails and your request never fires.
Where: Preflight responses. Add every custom header your frontend sends.
Set by: Server (preflight response)
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
• Access-Control-Allow-Credentials
What: Tells the browser whether to include cookies, authorization headers, or TLS client certificates in cross-origin requests.
Why: By default, cross-origin requests don't send cookies. If your API uses cookie-based auth (sessions), you must set this to true AND the frontend must set credentials: 'include' in the fetch call.
Where: Both preflight and actual responses. Required for cookie-based authentication across origins.
Set by: Server (response header)
Access-Control-Allow-Credentials: true // ⚠️ IMPORTANT: Cannot use wildcard origin with credentials! // This combination is INVALID: Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true // Browser rejects it. Must use specific origin when credentials are enabled.
• Access-Control-Max-Age
What: How long (in seconds) the browser can cache the preflight response. During this time, repeat requests skip the OPTIONS call.
Why: Preflight adds latency — an extra round trip before every non-simple request. Caching the preflight result means subsequent requests are fast. Without it, every single request gets double round-tripped.
Where: Preflight responses. Set high for stable APIs (86400 = 24 hours). Set low during development.
Set by: Server (preflight response)
Access-Control-Max-Age: 86400 ← Cache preflight for 24 hours Access-Control-Max-Age: 600 ← Cache for 10 minutes (during development) Access-Control-Max-Age: -1 ← Disable caching (every request triggers preflight)
• Access-Control-Expose-Headers
What: Lists which response headers JavaScript is allowed to read. By default, only 6 "safe" response headers are accessible to frontend code.
Why: Even if the server sends custom response headers (X-Total-Count, X-Request-ID, ETag), the browser hides them from JavaScript unless they're explicitly exposed.
Where: Actual (non-preflight) responses. Add any custom response headers your frontend needs to read.
Set by: Server (response header)
Access-Control-Expose-Headers: X-Total-Count, X-Request-ID, ETag // Default "safe" headers always readable (no need to expose): // Cache-Control, Content-Language, Content-Length, // Content-Type, Expires, Pragma
Preflight Requests (OPTIONS) Deep Dive
Preflight requests cause more confusion than any other part of CORS. Let me clear it up.
What triggers a preflight?
Any of these conditions make a request "non-simple" and trigger preflight:
| Condition | Example | Preflight? |
|---|---|---|
| GET with no custom headers | fetch('/api/users') | No |
| POST with form data | fetch('/api', { method: 'POST', body: formData }) | No |
| POST with JSON body | fetch('/api', { headers: { 'Content-Type': 'application/json' } }) | Yes ⚠️ |
| Any request with Authorization header | fetch('/api', { headers: { 'Authorization': 'Bearer ...' } }) | Yes |
| PUT or DELETE method | fetch('/api/users/1', { method: 'DELETE' }) | Yes |
| Custom headers (X-*) | fetch('/api', { headers: { 'X-Custom': 'value' } }) | Yes |
What does a preflight look like?
// Preflight REQUEST (sent automatically by browser) OPTIONS /api/users HTTP/1.1 Host: api.example.com Origin: https://myapp.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: Authorization, Content-Type // Preflight RESPONSE (your server must handle this) 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: Authorization, Content-Type Access-Control-Max-Age: 86400
Common preflight gotcha
If your server doesn't handle OPTIONS requests, the preflight gets a 404 or 405, and the browser blocks the actual request. Many routing frameworks need explicit OPTIONS handling:
// Express: cors middleware handles OPTIONS automatically
// But if you're doing manual CORS:
app.options('*', (req, res) => {
res.header('Access-Control-Allow-Origin', req.headers.origin)
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.sendStatus(204)
})Code Examples: Setting Up CORS
Here's how to configure CORS in the most common server environments. Copy, adapt, and you're done.
Express.js (Node.js)
// Option 1: Using the cors package (recommended)
const express = require('express')
const cors = require('cors')
const app = express()
// Allow specific origins
const corsOptions = {
origin: ['https://myapp.com', 'https://staging.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count', 'X-Request-ID'],
credentials: true,
maxAge: 86400, // Cache preflight for 24 hours
}
app.use(cors(corsOptions))
// Option 2: Dynamic origin (check against allowed list)
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://myapp.com', 'https://admin.myapp.com']
if (!origin || allowed.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
credentials: true,
}))
// Option 3: Manual headers (no package needed)
app.use((req, res, next) => {
const allowed = ['https://myapp.com']
const origin = req.headers.origin
if (allowed.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
return res.sendStatus(204)
}
next()
})Next.js (App Router)
// next.config.mjs — Set CORS headers globally
const nextConfig = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://myapp.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type,Authorization' },
{ key: 'Access-Control-Max-Age', value: '86400' },
],
},
]
},
}
export default nextConfig
// --- OR --- Route handler level (app/api/users/route.js)
export async function GET(request) {
const data = await getUsers()
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': 'https://myapp.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
},
})
}
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'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',
},
})
}Nginx
# Nginx CORS configuration
server {
location /api/ {
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
# Actual requests
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'X-Total-Count' always;
proxy_pass http://backend:3000;
}
}Frontend: fetch() with CORS
// Simple cross-origin request (no preflight)
const res = await fetch('https://api.myapp.com/users')
const data = await res.json()
// With credentials (cookies sent cross-origin)
const res = await fetch('https://api.myapp.com/users', {
credentials: 'include', // ← Send cookies cross-origin
})
// Non-simple request (triggers preflight)
const res = await fetch('https://api.myapp.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // ← Triggers preflight
'Authorization': 'Bearer token123', // ← Triggers preflight
},
body: JSON.stringify({ name: 'Alice' }),
credentials: 'include',
})
// ⚠️ You cannot set mode: 'no-cors' and read the response
// mode: 'no-cors' makes the response "opaque" — JS can't read it
const res = await fetch('https://api.example.com/data', {
mode: 'no-cors', // ← Response body is empty/inaccessible!
})Common CORS Errors and How to Fix Them
These are the CORS errors you'll actually encounter in real projects. I've debugged all of them at some point — here's the fix for each one.
Error 1: "No 'Access-Control-Allow-Origin' header is present"
Console: Access to fetch at 'https://api.example.com' from origin 'https://myapp.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Cause: The server response doesn't include any CORS headers at all.
Fix: Add Access-Control-Allow-Origin to your server response. The fix is always on the server side.
Error 2: "The value of 'Access-Control-Allow-Origin' header must not be wildcard '*' when credentials mode is 'include'"
Cause: You set Access-Control-Allow-Origin: * but also have credentials: 'include' on the frontend or Access-Control-Allow-Credentials: true on the server.
Fix: Replace the wildcard with the specific requesting origin. Dynamically set it from the Origin request header (after validating against your allowed list).
Error 3: "Method DELETE is not allowed by Access-Control-Allow-Methods"
Cause: Your preflight response doesn't include the HTTP method being used in the Access-Control-Allow-Methods header.
Fix: Add the method to your CORS config: Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Error 4: "Request header field 'authorization' is not allowed by Access-Control-Allow-Headers"
Cause: You're sending a custom header (Authorization, X-Custom-Header) that isn't listed in Access-Control-Allow-Headers.
Fix: Add the header to your preflight response: Access-Control-Allow-Headers: Content-Type, Authorization
Error 5: Preflight returns 404 or 405
Console: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
Cause: Your server isn't handling OPTIONS requests. The router returns 404 (no route) or 405 (method not allowed).
Fix: Add an OPTIONS handler to your routes, or use CORS middleware that handles it automatically. Return 204 No Content with CORS headers.
Error 6: CORS works on GET but fails on POST (JSON)
Cause: GET is a simple request (no preflight). But POST with Content-Type: application/json triggers a preflight, and your server doesn't handle it.
Fix: Ensure your CORS config responds to OPTIONS requests and allows the Content-Type header.
Error 7: Works in development, fails in production
Cause: Usually one of: (1) Your proxy/CDN strips CORS headers, (2) Origin URL changed (http vs https, www vs non-www), (3) CORS is set on the dev server but not the production reverse proxy.
Fix: Check the exact origin in production. Verify your Nginx/Cloudflare/CDN passes through CORS headers. Use curl -I to test.
Error 8: "Works in Postman but not the browser"
Cause: Postman doesn't enforce CORS — it's not a browser. CORS is a browser-only security mechanism. If it works in Postman but not the browser, the issue is specifically missing/incorrect CORS headers.
Fix: Don't use Postman to test CORS. Use the browser Network tab to see actual preflight requests and response headers. Fix the headers on your server.
CORS vs CSRF vs CSP: What's the Difference?
These three acronyms get confused constantly. They're all browser security mechanisms, but they solve completely different problems.
| Aspect | CORS | CSRF | CSP |
|---|---|---|---|
| What it is | Response header mechanism | Attack type (and its prevention) | Response header policy |
| Problem it solves | Controls who can READ your API responses | Prevents unauthorized actions (state changes) | Controls what resources a page can LOAD |
| Enforced by | Browser (checks response headers) | Server (validates tokens/origin) | Browser (checks response header) |
| Configured on | Server (response headers) | Server (generates/validates tokens) | Server (response header) |
| Protects against | Unauthorized data reading | Forged form submissions, unwanted actions | XSS, code injection, data exfiltration |
| Example | API blocks requests from evil.com | Bank requires CSRF token for transfers | Page blocks inline scripts and external JS |
| Key header | Access-Control-Allow-Origin | X-CSRF-Token (custom), SameSite cookie | Content-Security-Policy |
| Not a replacement for | Authentication/authorization | CORS (different problem) | Input validation, CORS |
In short: CORS = who can read my data. CSRF = preventing unwanted actions. CSP = what my page is allowed to load. You usually need all three in a production app.
CORS Best Practices
After spending hours debugging CORS on various projects, these are the rules I follow:
Always specify exact origins — avoid wildcard in production. * is fine for truly public, unauthenticated APIs. For everything else, whitelist specific origins.
Set Access-Control-Max-Age for stable APIs. 86400 seconds (24 hours) eliminates redundant preflight requests. Huge latency win for SPAs making many API calls.
Use a CORS middleware/library instead of manual headers. The cors npm package, Django CORS headers, and Spring CORS config all handle edge cases you'll miss manually.
Always handle OPTIONS requests explicitly. If your framework doesn't auto-handle them, add a catch-all OPTIONS route that returns 204 with CORS headers.
Validate the Origin header server-side. Don't blindly reflect the Origin header — that's as insecure as using *. Check against an explicit allowlist.
Set the Vary: Origin header when dynamically setting origins. Prevents CDNs from caching a response with one origin and serving it to another. Critical for multi-origin setups.
Only expose headers the frontend actually needs. Don't use Access-Control-Expose-Headers: * — list specific headers to maintain least-privilege.
Test CORS in the browser, not in Postman. Postman doesn't enforce CORS. Always verify with actual browser requests during development.
Keep credentials: 'include' only where needed. It requires specific origins (not wildcard), adds complexity, and sends cookies you might not intend to send.
Document your CORS policy alongside your API docs. Tell consumers which origins are allowed, whether credentials are supported, and any rate limits on preflight.
Common CORS Mistakes
I've made most of these at one point or another. Save yourself the debugging time.
Trying to fix CORS on the frontend. Adding headers to your fetch request doesn't fix CORS. The fix is always server-side. Your frontend can't override browser security policies.
Using a CORS proxy in production. Services like cors-anywhere are fine for demos but not production. They add latency, are unreliable, and expose your traffic to a third party.
Setting Access-Control-Allow-Origin to multiple origins in one header. The spec only allows a single origin value (or *). To support multiple, dynamically check the Origin header and reflect the matching one.
Blindly reflecting the Origin header without validation. If you just echo back whatever Origin the request sends, any website can access your API. Always validate against a whitelist.
Forgetting to add CORS headers on error responses. If your server throws a 500 without CORS headers, the browser shows a CORS error instead of the actual error. Always set CORS headers even on errors.
Thinking CORS replaces authentication. CORS controls which origins can call your API from a browser. It doesn't authenticate or authorize requests. You still need proper auth on every endpoint.
Disabling CORS entirely with a browser extension. Great for local debugging, terrible habit. You'll ship code that only works with the extension enabled and not in any real browser.
Not setting Vary: Origin with dynamic origins. Without it, a CDN might cache a response with Origin A's allowed-origin header and serve it to requests from Origin B, breaking CORS for B.
Frequently Asked Questions
What is CORS?▼
Why does CORS exist?▼
What is a preflight request?▼
How do I fix 'Access-Control-Allow-Origin' error?▼
What is the difference between simple and preflight requests?▼
Can I fix CORS from the frontend?▼
Should I use Access-Control-Allow-Origin: * in production?▼
What is Access-Control-Max-Age?▼
Why does my CORS work in Postman but not the browser?▼
What is the difference between CORS, CSRF, and CSP?▼
Related Articles & Tools
Conclusion
CORS feels like the browser is fighting you, but it's actually protecting your users. The Same-Origin Policy prevents malicious sites from stealing data, and CORS is the controlled exception that lets legitimate cross-origin communication happen.
The mental model is simple: your server tells the browser who's allowed to read responses. If the requesting origin isn't on the list, the browser blocks the read. Preflight requests are just the browser asking permission before sending anything "unusual."
Once you internalize the flow — Origin check → preflight (if non-simple) → response headers → browser decision — you'll fix CORS issues in seconds instead of hours. And you'll stop reaching for Access-Control-Allow-Origin: * as a band-aid.
Set specific origins, handle OPTIONS, cache your preflights, and test in the actual browser. That's 90% of CORS done right.
