Originally published at Byte by Mahmud
Your request already worked. That's the confusing part.
The first thing nobody tells you about CORS is that the error doesn't mean your request failed. The browser sent it. The server answered it. The browser read the response, decided your JavaScript wasn't allowed to see it, and threw it away.
Cross-Origin Resource Sharing is a browser rule, not a network rule. An origin is the scheme plus the host plus the port, all three — so https://app.example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:8000. Your page's fetch carries an Origin header, and the browser inspects the response for permission before JavaScript gets to touch it. The check happens after the round trip, not before.
The browser blocks reading the response, not sending the request. So when you see the red console line, your first question shouldn't be "did my fetch break?" — it should be "did that mutation run twice because I retried it?" curl ignores CORS entirely, which is why your endpoint works in the terminal and dies in the browser.
Step 1: find out which response is actually broken
Open DevTools, go to the Network tab, and reproduce the error. You're looking for one of two things: an OPTIONS request (a preflight) that came back wrong, or the real request whose response lacks the headers.
The browser sends a preflight before the real request whenever the request isn't "simple": anything using PUT, PATCH, or DELETE, anything carrying an Authorization header or a custom header, and anything with a Content-Type of application/json — JSON isn't one of the three safelisted types, which is why nearly every API call gets preflighted. The preflight is an OPTIONS request that asks "would you accept this method and these headers?" — and it must get a 2xx answer with the CORS headers on it. A 401, a 403, or a redirect kills the actual request before it's ever sent.
This is where half of all CORS debugging time goes. A 502 from the proxy or a 401 from your auth layer usually skips whatever middleware adds the CORS headers — so your frontend sees a CORS error when the real problem is "the upstream died" or "the session expired." A trailing-slash or login redirect swallows the preflight just as cleanly.
So: look at the preflight's response. Does it have Access-Control-Allow-Origin? Did it return 204 or 200? Then look at the actual response. To reproduce both exchanges outside the browser, use curl with an Origin header:
curl -i 'https://api.example.com/profile' \
-H 'Origin: https://app.example.com'
curl -i -X OPTIONS 'https://api.example.com/profile' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: authorization'
This shows you exactly what the server sends. It can't tell you what the browser will allow — for that, run the fetch from your real frontend origin. One more thing MDN is blunt about: for security reasons, the details of a CORS failure are not available to JavaScript. All your code knows is that an error happened. The console has the specifics; the promise rejection never will.
Step 2: the five headers that matter, and the two traps
Once you know which response is broken, five headers do all the work:
Access-Control-Allow-Origin takes exactly one origin or *. A comma-separated list does not work — two origins in the header is a rejection, not a choice. If you serve more than one frontend, compare the request's Origin against your allowlist, echo back the one that matched, and send Vary: Origin so caches don't hand one origin's response to another. Match origins exactly: https://app.example.com.evil.test is not your app, but a sloppy includes('example.com') check would say it is.
Access-Control-Allow-Methods answers the preflight's method question — and it must be this header, not the regular Allow header, which the browser ignores here. If the preflight asks for DELETE, DELETE must appear in the response.
Access-Control-Allow-Headers lists every non-safelisted request header you accept. List Authorization by name. The wildcard * does not cover it in all browsers — this is the single most common "but I set everything to wildcard" failure.
Access-Control-Allow-Credentials is the trap header. The moment your frontend sends credentials: 'include' (cookies) or the server needs to permit them, the response must name the exact origin and send Access-Control-Allow-Credentials: true. A * origin fails even when there's no cookie to send — the browser rejects the combination outright. Note the two halves of this that people conflate: setting an Authorization header yourself triggers a preflight, but it does not switch the request to credentials mode. Those are separate switches.
Access-Control-Max-Age sets how long the browser may cache a preflight answer. Without it, the Fetch spec defaults to five seconds — and browsers apply their own upper limits. When you test a policy change, use a fresh private window; a cached preflight means no OPTIONS request appears while the actual response is still being checked.
In practice, most stacks have a ready-made answer. Express's cors middleware handles preflights itself, mounted before auth and your API routes:
const cors = require('cors')
app.use('/api', cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 600,
}))
app.use('/api', express.json())
Laravel 11 ships the same idea as config/cors.php — set 'paths' => ['api/*'], name your frontend in 'allowed_origins', and flip 'supports_credentials' to true if you send cookies. And if a reverse proxy owns the boundary instead of the app, pick one place to own it: two layers setting their own policy is how you get the dreaded "Multiple CORS header not allowed" error. In nginx, nested locations don't inherit add_header, and the always variant is what puts the headers on error responses too — because a 502 without headers is a CORS error in disguise.
Step 3: when you can't touch the server
Sometimes the API isn't yours. Three options exist, in order of legitimacy.
The dev shortcut: proxy the API through your dev server. In Vite, server.proxy forwards paths to the real backend server-side, where CORS doesn't apply:
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\//, ''),
},
},
},
})
The browser sees only same-origin requests. This is development-only — it never fixes production.
The production answer: relay through a server you control. A backend route that fetches the external API and returns the body to your frontend is a same-origin request — the CORS problem disappears. MDN states this plainly: it adds latency and a dependency on your proxy, but it works when you can't change the upstream's headers.
And two things you must not do: adding Access-Control-Allow-Origin to the request (it's a response header — only the server can send it), and reaching for mode: 'no-cors'. The latter hands you an opaque response — status 0, empty headers, unreadable body. That's a feature for analytics beacons, not a fix for a JSON API. Launching the browser with web security disabled belongs in the same bin: it fixes your machine and nobody else's.
The takeaway
CORS errors feel mysterious because the failure is reported by your frontend but caused by the server. The order that actually works: check whether the request already ran on the server (it probably did), find the broken response in the Network tab — preflight or actual, error responses included — fix the headers on that exact response, and test in a fresh profile so the preflight cache doesn't lie to you. It's a server configuration wearing a frontend error message. Treat it like one.
Top comments (0)