CORS Expliqué Why Your API Keeps Blocking You (and How to Fix It)
CORS errors are the bane of every frontend developer's existence. Here's what the browser is actually enforcing, why the wildcard fix fails with credentials, and how to configure CORS correctly the first time.
You just wrote a fetch call. The backend is running. You open DevTools and see: Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy. Congratulations — you’ve hit one of the most reliably confusing errors in web development.
Here’s the thing: CORS isn’t a bug, and it isn’t your API being hostile. It’s the browser enforcing a security policy on your behalf. Once you understand what it’s actually doing, fixing it takes about five minutes.
The Same-Origin Policy: Why This Exists
Browsers enforce the Same-Origin Policy (SOP) to stop malicious websites from silently reading data from other sites using your logged-in session. Without it, a page at evil.com could fire a request to yourbank.com/api/balance, and the browser would happily send your bank cookies along — then hand the response back to evil.com.
Two URLs share the same origin if and only if they match on all three of these:
- Protocole —
http://vshttps://are different origins - Hôte —
api.example.comvsexample.comare different origins - Port —
example.com:3000vsexample.com:8080are different origins
So when your React app on http://localhost:3000 calls an Express API on http://localhost:4000, the browser sees a cross-origin request and CORS kicks in.
Simple Requests vs. Preflighted Requests
Not all cross-origin requests are treated the same. The browser splits them into two categories:
Simple Requests
A request is “simple” if it uses GET, HEAD, ou POST with only these headers: Accept, Accept-Language, Content-Language, ou Content-Type with values application/x-www-form-urlencoded, multipart/form-data, ou text/plain.
For a simple request, the browser sends it directly and checks the Access-Control-Allow-Origin header on the response. If the origin isn’t listed, it blocks the response from JavaScript — the request still hit your server, but JS can’t read the result.
Preflighted Requests
Anything outside those constraints triggers a preflight: the browser first sends an OPTIONS request to the same URL, asking “are you okay with this?”. The preflight carries:
OPTIONS /api/data HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Content-Type, Authorization
Your server has to respond to that OPTIONS request with the appropriate CORS headers avant the browser will send the actual request. This catches a lot of developers off guard — more on that below.
The CORS Response Headers That Actually Matter
Here’s what each header does:
Access-Control-Allow-Origin
The most important one. Tells the browser which origins are allowed to read the response. You can set it to a specific origin or the wildcard *:
Access-Control-Allow-Origin: https://app.example.com
# or
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods
Lists which HTTP methods are permitted. Used in preflight responses:
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers
Which request headers the client is allowed to send. If your frontend sends Authorization or a custom header and it’s not listed here, the preflight fails:
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Allow-Credentials
When set to true, tells the browser it’s okay to send cookies and HTTP auth headers with cross-origin requests — but seulement if the frontend also opts in with credentials: 'include':
Access-Control-Allow-Credentials: true
Access-Control-Max-Age
How long (in seconds) the browser can cache the preflight response. Without it, every single request gets a preflight. Set it to something reasonable to save round trips:
Access-Control-Max-Age: 86400
Want to quickly check what headers your API is actually returning? The Analyseur d'en-têtes HTTP lets you inspect response headers without touching curl.
The Credentials + Wildcard Trap
This is the most common “I followed the tutorial and it still doesn’t work” situation. When your request includes credentials (cookies, HTTP auth), the browser refuses to accept Access-Control-Allow-Origin: *. Full stop. The spec explicitly disallows it.
So this combination will always fail:
// Frontend
fetch('https://api.example.com/user', {
credentials: 'include' // sends cookies
});
// Backend response headers — THIS DOESN'T WORK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
The fix is to echo back the specific requesting origin instead of using the wildcard:
// Backend response headers — correct
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
If you have multiple valid origins, you need to check the request’s Origin header against an allowlist and reflect the matching one dynamically — not hardcode all of them (you can only return one value).
The Forgotten OPTIONS Handler
Here’s a mistake that trips up people building REST APIs: they add CORS headers to their GET et POST route handlers, but forget that preflighted requests arrive as OPTIONS — a method their router never handles.
The browser sends OPTIONS /api/data and gets back a 404 ou 405 Method Not Allowed. Preflight fails. The actual request never happens. The error in DevTools just says CORS, so it looks like a header problem.
Your server must respond to OPTIONS requests with a 200 (ou 204) and the correct CORS headers. In Express, you can handle this globally before your routes:
app.options('*', (req, res) => {
res.set({
'Access-Control-Allow-Origin': req.headers.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',
});
res.sendStatus(204);
});
A Practical Express.js CORS Middleware Example
Here’s a production-ready CORS middleware for Express that handles the dynamic origin pattern, credentials, preflight caching, and the Vary: Origin header (which tells CDNs and proxies not to serve the same cached response to different origins):
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
'http://localhost:3000',
]);
function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
// Always set Vary so caches know the response depends on Origin
res.set('Vary', 'Origin');
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
// Handle preflight
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.set('Access-Control-Max-Age', '86400');
return res.sendStatus(204);
}
next();
}
app.use(corsMiddleware);
Le Vary: Origin header is easy to forget and causes subtle caching bugs in production. Without it, a CDN might cache a response with Access-Control-Allow-Origin: https://app.example.com and serve it to a different origin that shouldn’t have access — or vice versa.
If you’re not sure whether your headers are set up correctly, the Constructeur et validateur d'en-têtes CORS lets you construct and validate your CORS configuration before deploying anything.
Don’t Disable CORS in Your Browser
Searching for a quick fix often surfaces advice to launch Chrome with --disable-web-security. Please don’t. This disables the Same-Origin Policy entirely — not just CORS. You’re now browsing the web with no cookie isolation between sites. Any site you visit can read data from any other site you’re logged into.
The right local dev fix is a dev proxy. Vite has one built in. When the browser makes a request to http://localhost:3000/api/..., the Vite dev server proxies it to your backend — same origin as far as the browser is concerned, so CORS never triggers:
// vite.config.ts
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
};
Now your frontend calls /api/users and Vite proxies it to http://localhost:4000/users. No CORS, no browser security workarounds, and it mirrors how a reverse proxy works in production.
Quick Troubleshooting Checklist
When a CORS error hits, run through this before reaching for Stack Overflow:
- Check the Network tab in DevTools — does the preflight OPTIONS request exist? Did it get a 2xx response?
- Is
Access-Control-Allow-Originset in the response headers (not the request headers)? - Are you using credentials? If yes,
*won’t work — use the exact origin. - Does your router handle
OPTIONSfor the route you’re calling? - Is
Authorizationlisted inAccess-Control-Allow-Headers? - Is
Vary: Originset if you’re dynamically reflecting origins?
Installez nos extensions
Ajoutez des outils IO à votre navigateur préféré pour un accès instantané et une recherche plus rapide
恵 Le Tableau de Bord Est Arrivé !
Tableau de Bord est une façon amusante de suivre vos jeux, toutes les données sont stockées dans votre navigateur. D'autres fonctionnalités arrivent bientôt !
Outils essentiels
Tout voir Nouveautés
Tout voirMise à jour: Notre dernier outil was added on Juin 26, 2026
