Understanding CORS and How to Handle It
Your React app on localhost:5173 calls the API on localhost:8000. The same request works in Postman and with cURL, the server logs show a 200, and yet the browser console shows a red CORS error and your fetch rejects with a vague TypeError: Failed to fetch. Most people's first move is to paste Access-Control-Allow-Origin: * somewhere and hope.
This post explains what's actually going on: why the browser blocks the response, how simple and preflight requests differ, which headers matter, and how to configure CORS properly in Node.js, Express, Laravel and Nginx without opening a hole in your API.
- What CORS is and why browsers enforce it
- The difference between simple and preflight requests
- The CORS headers you'll actually touch
- How to configure CORS in Node.js, Express, Laravel, and Nginx
- Common CORS errors and how to fix them
- What to check before you ship a CORS config
What is CORS?
Browsers start from the Same-Origin Policy (SOP): JavaScript on one origin can't read responses from another origin. Cross-Origin Resource Sharing (CORS) is the way a server says "this other origin is allowed to read my responses." Without it, scripts can send cross-origin requests, but they don't get to see what comes back.
Origins consist of protocol, domain, and port. Any difference means "cross-origin"
An origin is three things: the protocol (http/https), the domain (example.com) and the port (80, 443, 3000, etc.). Change any one of them and you're cross-origin. That's why localhost:3000 talking to localhost:8000 trips CORS even though it's the same machine.
Same Origin Examples
https://app.com/page1 → https://app.com/page2https://app.com:443/a → https://app.com/b
Cross Origin Examples
https://app.com → http://app.com (protocol)https://app.com → https://api.app.com (subdomain)
Why Does CORS Exist?
The restriction protects users, not servers. Your browser holds cookies for every site you're logged into. Without the Same-Origin Policy, any page you open could use them:
- Steal sensitive data: read your webmail, your bank balance, anything behind a login
- Act on your behalf: transfer money, change passwords, post content you never wrote
- Ride your sessions: use your logged-in cookies against any site you're authenticated to
Imagine you visit evil-site.com while logged into your bank. Without same-origin restrictions, evil-site.com's JavaScript could:
- Fetch your account details from
bank.com/api/balance - Initiate a transfer to the attacker's account
- All using YOUR authenticated session cookies
CORS is a controlled hole in that wall. The server lists which origins may read its responses, and the browser enforces that list. Your SPA on app.com gets to talk to api.app.com, and a random site still doesn't.
How CORS Works
On a cross-origin request, the browser adds an Origin header, then looks at the response headers to decide whether your JavaScript gets to see the result. All of the server's policy lives in those headers.
Simple Requests vs. Preflight Requests
There are two kinds of request here. Simple requests go straight to the server. Anything else gets a preflight: the browser first sends an OPTIONS request asking permission, and only sends the real request if the answer is yes.
Simple requests go directly to the server; preflight requests require an OPTIONS check first
Preflight protects servers written before CORS existed. An old API might happily run a DELETE from any page without ever expecting a cross-origin caller. The OPTIONS check means the server has to opt in before the browser sends anything that could change state. A practical side effect: sending JSON with Content-Type: application/json always triggers a preflight, so your API must answer OPTIONS correctly.
The CORS Headers You'll Actually Use
There aren't many of them. Most bugs come from one of these being missing or slightly wrong.
Response Headers (Server → Browser)
| Header | Description | Example Value |
|---|---|---|
Access-Control-Allow-Origin |
Specifies which origin(s) can access the resource | https://app.com or * |
Access-Control-Allow-Methods |
HTTP methods allowed for cross-origin requests | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers |
Custom headers the client is allowed to send | Content-Type, Authorization |
Access-Control-Allow-Credentials |
Whether cookies/auth headers can be included | true |
Access-Control-Max-Age |
How long preflight results can be cached (seconds) | 86400 |
Access-Control-Expose-Headers |
Response headers your JavaScript is allowed to read | X-Request-Id, X-Rate-Limit |
Request Headers (Browser → Server)
| Header | Description | Example Value |
|---|---|---|
Origin |
The origin making the request (sent automatically) | https://app.com |
Access-Control-Request-Method |
The HTTP method of the actual request (preflight only) | DELETE |
Access-Control-Request-Headers |
Custom headers to be sent (preflight only) | Content-Type, Authorization |
The Classic CORS Error
You've seen this one, probably in Chrome:
It means the response came back without an Access-Control-Allow-Origin header, or with one that didn't match the requesting origin. Your code only sees a generic network error; the detailed reason is only in the console.
The server doesn't block anything. For simple requests, the request reaches the server and gets processed. The browser just refuses to hand the response to your JavaScript. So:
- Server-to-server requests work fine (no browser = no CORS)
- Postman, cURL, and other tools ignore CORS
- The fix belongs on the server (or a proxy in front of it), never in frontend code
Implementing CORS
Pick the layer that owns your responses. If the app framework handles CORS, don't also add headers in Nginx, or you'll end up with duplicates.
Manual CORS implementation in vanilla Node.js:
const http = require('http');
const server = http.createServer((req, res) => {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Your actual route handling
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello from API' }));
});
server.listen(3000);
Use the cors middleware rather than writing headers by hand:
const express = require('express');
const cors = require('cors');
const app = express();
// Simple usage - allow all origins (not for production!)
app.use(cors());
// Production-ready configuration
const corsOptions = {
origin: ['https://myapp.com', 'https://admin.myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
exposedHeaders: ['X-Request-Id'] // Headers client can access
};
app.use(cors(corsOptions));
// Dynamic origin based on request
const dynamicCors = cors({
origin: (origin, callback) => {
const allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
// Allow requests with no origin (mobile apps, curl)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
}
});
app.use(dynamicCors);
Laravel includes CORS middleware since version 7. Configure in config/cors.php:
<?php
return [
/*
* Which paths should handle CORS
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
/*
* Matches request origins against patterns
*/
'allowed_origins' => [
'https://myapp.com',
'https://admin.myapp.com',
],
// Or use patterns
'allowed_origins_patterns' => [
'https://*.myapp.com',
],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => ['X-Request-Id'],
'max_age' => 86400,
'supports_credentials' => true,
];
On Laravel 7 to 10, make sure HandleCors is in the global middleware stack in app/Http/Kernel.php. Laravel 11+ has no Kernel.php and registers it by default; run php artisan config:publish cors to get the config file.
Add CORS headers at the server level in Nginx:
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
# CORS headers
add_header 'Access-Control-Allow-Origin' 'https://myapp.com' 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;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://myapp.com';
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;
add_header 'Content-Type' 'text/plain';
return 204;
}
proxy_pass http://backend;
}
}
CORS with Credentials
Once cookies or HTTP auth are involved, the rules get stricter. You need all three of these, and missing any one gives you a CORS error.
Server: Enable credentials
Set Access-Control-Allow-Credentials: true in response headers.
Server: Specify exact origin
You cannot use * for Allow-Origin when credentials are enabled. You have to send back the exact origin.
Client: Include credentials in request
Set credentials: 'include' in fetch or withCredentials: true in Axios.
// Fetch API
fetch('https://api.example.com/user', {
method: 'GET',
credentials: 'include', // Include cookies
headers: {
'Content-Type': 'application/json',
}
});
// Axios
axios.get('https://api.example.com/user', {
withCredentials: true // Include cookies
});
// Axios global config
axios.defaults.withCredentials = true;
The spec forbids Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true, and browsers reject the response if you try. Otherwise any website could read your API's responses using the visitor's cookies.
Common CORS Errors and Solutions
Error: No 'Access-Control-Allow-Origin' header
Cause: Server isn't sending CORS headers.
Solution: Add the Access-Control-Allow-Origin header to your server responses, on the routes actually being called. Also check the status code in the Network tab. If it's a 500 or 401, the real problem is often an error handler or auth middleware that returns before the CORS middleware adds its headers, so the browser reports a CORS error that's hiding the actual failure.
res.setHeader('Access-Control-Allow-Origin', 'https://your-frontend.com');
Error: Origin not in Access-Control-Allow-Origin
Cause: The requesting origin doesn't match the allowed origin.
Solution: The match is an exact string comparison on protocol, domain and port. Look for trailing slashes and www vs non-www.
// Wrong: port mismatch
'https://localhost:3000' !== 'https://localhost:3001'
// Wrong: trailing slash
'https://app.com/' !== 'https://app.com'
// Wrong: www mismatch
'https://www.app.com' !== 'https://app.com'
Error: Request header not allowed
Cause: You're sending a custom header that isn't in Access-Control-Allow-Headers.
Solution: Add the header to your CORS configuration.
res.setHeader('Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Custom-Header');
Error: Method not allowed
Cause: The HTTP method (PUT, DELETE, etc.) isn't in Access-Control-Allow-Methods.
Solution: Add the method to your allowed methods list.
res.setHeader('Access-Control-Allow-Methods',
'GET, POST, PUT, DELETE, PATCH, OPTIONS');
Error: Wildcard (*) with credentials
Cause: Using * for Allow-Origin while also requiring credentials.
Solution: Check the request's Origin against an allowlist and echo it back only if it matches.
const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
Security Best Practices
Every line of a CORS config decides who can read your API's responses from a browser. Treat it like any other access rule:
CORS Security Checklist
- No
*on private APIs: list the exact origins. A truly public, read-only API without cookies is the one place*is fine. - Validate the Origin header: check it against an allowlist, don't blindly echo it back
- Be specific with methods: only allow the HTTP methods your API actually uses
- Limit allowed headers: only permit headers your frontend actually sends
- Set a sensible Max-Age: caching preflight results cuts down on OPTIONS requests (browsers cap it, Chrome at 2 hours)
- Only enable credentials if you need them: cookies or HTTP auth, nothing else
- Keep CORS bypasses in development: proxies and browser flags aren't a production fix
- Log rejected origins: they point to either a misconfiguration or someone probing
Setting Access-Control-Allow-Origin to whatever the request's Origin header says turns CORS off in practice, and with credentials enabled it lets any site read your users' data. Validate against an allowlist. Same goes for sloppy regexes: a pattern like myapp\.com$ also matches evilmyapp.com.
Development Workarounds
Locally you often just want the frontend to talk to the API. These are the options, from best to worst:
Proxy Server
Configure your dev server (Vite, Webpack, CRA) to proxy API requests. The proxy runs same-origin, so no CORS issues.
Dev-only CORS
Enable permissive CORS only in development mode. Use environment variables to control the configuration.
Browser Extensions
Extensions like "CORS Unblock" disable CORS for testing. Turn them off when you're done; they also hide bugs your users will hit.
Chrome Flags
Launch Chrome with --disable-web-security. Only for isolated testing environments.
export default {
server: {
proxy: {
// Proxy /api requests to backend
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
Debugging CORS Issues
Start in the browser's dev tools, then confirm with cURL:
Check the Network Tab
Look at the actual request and response headers. Check that Origin, Access-Control-Allow-Origin and the other CORS headers are there and match. Also check the status code: a CORS error on a 500 is usually a server error first.
Look for Preflight (OPTIONS)
If your request triggers a preflight, check that the OPTIONS request succeeds and returns the CORS headers. If it fails, the real request never gets sent. Chrome sometimes hides OPTIONS requests unless you clear the type filter.
Test with cURL
Send the same headers the browser would and read the raw response. That takes caching, extensions and the browser itself out of the picture.
# Check simple request headers
curl -I -H "Origin: https://myapp.com" \
https://api.example.com/data
# Simulate preflight request
curl -X OPTIONS \
-H "Origin: https://myapp.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-I https://api.example.com/data
# Look for these in response:
# Access-Control-Allow-Origin: https://myapp.com
# Access-Control-Allow-Methods: POST, GET, ...
# Access-Control-Allow-Headers: Content-Type, Authorization
What to Check Before Going to Production
Things that bite after deploy
- Do you need CORS at all? If you can serve the frontend and API from the same origin (say
app.comandapp.com/apibehind one Nginx), you skip CORS and preflight latency entirely. I'd pick that over a clever CORS config whenever the architecture allows it. - Add
Vary: Originwhen the allowed origin is dynamic. Without it, a CDN or reverse proxy can cache a response withAccess-Control-Allow-Origin: https://admin.myapp.comand serve it tomyapp.com, which then fails at random. Thecorspackage and Laravel add it for you; hand-written code usually doesn't. - Headers on error responses. Your 401s, 422s and 500s need CORS headers too, or the frontend can't read the error body and just shows "Failed to fetch". In Nginx that's what the
alwaysflag is for. - Exactly one
Access-Control-Allow-Originheader. Setting it in both the app and Nginx produces "contains multiple values", which the browser rejects. - Staging and preview URLs. Make sure they're in the allowlist via environment config, not by loosening the production rule.
And remember what CORS doesn't do. It isn't authentication and it doesn't stop cURL, scripts or other servers from calling your API. It only controls what browsers let other sites read. Your API still needs real auth, and for cookie-based sessions, CSRF protection too.