Blocked by CORS Policy: Fixing Failed Browser Requests
Browsers block cross-origin responses when the server omits Access-Control-Allow-Origin.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Basic JavaScript and fetch API knowledge
- ✓A backend you can configure or inspect
- ✓Familiarity with browser DevTools
- The server omitted Access-Control-Allow-Origin, so the browser got the response but hid it from your script. Fix it server-side by returning that header with your exact origin.
- The request still reaches the server, which is why curl succeeds while fetch fails. Verify in DevTools Network by inspecting the response headers.
- JSON bodies and custom headers trigger an OPTIONS preflight. Answer it with 2xx plus Access-Control-Allow-Methods and Access-Control-Allow-Headers.
- With cookies or Authorization headers, use credentials: 'include' and echo one explicit origin. A wildcard * never works with credentials.
Picture your browser as a cautious mailroom clerk. Your page requests a parcel from another company's warehouse. The clerk fetches it but demands a signed release note naming your company, and that note is the Access-Control-Allow-Origin header. No note, no parcel. Tools like curl skip the clerk and walk into the warehouse themselves, so they succeed where your page fails. The goods were never missing, and only the warehouse can sign the note, which is why every CORS fix happens on the server.
You wire up fetch to your API, the endpoint answers perfectly in curl, and the browser slaps you with red text: blocked by CORS policy. Nothing is wrong with your JavaScript. Nothing is wrong with your URL. The server answered, the bytes arrived, and the browser deliberately threw them away. That moment confuses every web developer exactly once, because the error reads like a network failure while describing a security decision.
The decision belongs to the same-origin policy, the rule that a page may only read responses from its own origin unless the other side explicitly opts in. Scheme, host, and port must all match. Your app at app.example.com calling api.example.com is cross-origin. Your localhost page calling a staging API is cross-origin. Each of those reads needs the server's written permission, delivered as response headers.
This error fires in a handful of repeatable situations. The server omits Access-Control-Allow-Origin. The request triggers an OPTIONS preflight the server never answers. The client sends cookies while the server answers with a wildcard. A proxy strips the headers. Each prints a different console message with its own server-side fix.
This guide walks through all of them. You'll learn what the browser checks, how to read the failing headers in DevTools, why curl keeps lying to you, and how to fix the server in Express, Spring, Nginx, and Django.
Same-Origin Policy Blocks the Read, Never the Request
An origin is the triple of scheme, host, and port. https://app.example.com reads as scheme https, host app.example.com, port 443. Change any one of the three and you have a different origin: http instead of https, api instead of app, or :3001 instead of :443. The same-origin policy says a page may freely read responses from its own origin and nothing else, unless the foreign server explicitly grants permission.
The critical detail is what gets blocked. The policy blocks the read, never the request. When your script calls fetch on a cross-origin URL, the browser sends the HTTP request, the server processes it, and the response comes back over the wire. Then the browser inspects the response headers. Without a matching Access-Control-Allow-Origin value, it refuses to expose the body, status, or headers to your script and reports that the request was blocked by CORS policy. Network-level tools confirm this: the response exists, your code just can't see it.
This design protects authenticated sessions. Without it, any page you visit could fire requests at your bank or webmail with your cookies attached and read the results. The policy lets the request carry credentials but denies the foreign page the response, which closes the data-theft hole while keeping plain navigation working. Simple top-level navigation like clicking a link was never restricted; only script-driven reads are.
CORS is the exemption mechanism, not a separate security layer. The server opts specific origins in by echoing them in Access-Control-Allow-Origin, and the browser enforces that opt-in. No header means no permission, and the default-deny stance is intentional. Every fix in this guide is therefore a server change: you teach the server to grant permission, and the browser stops discarding responses.
Preflight OPTIONS Requests Ask Permission Before Sending Data
Simple requests skip the preflight. A request counts as simple when it uses GET, HEAD, or POST with only safelisted headers like Accept or Content-Type limited to form-like values. Everything else triggers a preflight: methods like PUT, PATCH, or DELETE, any custom header such as Authorization or X-Request-Id, and content types like application/json. Since most real APIs speak JSON, most real API calls get preflighted.
The preflight is an OPTIONS request the browser sends before your actual call. It carries Origin plus two questions: Access-Control-Request-Method naming the method you want, and Access-Control-Request-Headers listing your custom headers. The server must answer with a 2xx status and its own permissions: Access-Control-Allow-Methods listing the methods it accepts and Access-Control-Allow-Headers listing the headers it accepts. Only when that answer satisfies the browser does it send your real request.
Three server mistakes break this handshake. Returning 404 or 405 for OPTIONS because no route handles it. Answering 401 because auth middleware rejects a request that carries no credentials by design. Or returning 200 with no CORS headers because the middleware that adds them runs after the OPTIONS handler returns. Any of these makes the browser cancel the real request and blame CORS.
Preflight answers are cached for the window given by Access-Control-Max-Age, so one OPTIONS exchange covers minutes of subsequent calls. During development that cache hides your fixes, which is why seasoned developers test with DevTools cache disabled. In production the cache is your friend: it keeps chatty single-page apps from doubling every API call.
Allowed Origins, Wildcards, and the Credentials Trap
Access-Control-Allow-Origin accepts exactly one origin or a wildcard, never a list. To serve several frontends, the server reads the request's Origin header, checks it against an allowlist, and echoes the match back verbatim. That echo must be byte-exact: https://app.example.com is not http://app.example.com, and a trailing slash breaks the match. Pair the echo with Vary: Origin so shared caches store one response per origin instead of serving site A's permission to site B.
Credentials mode changes the rules. Fetch defaults to same-origin, meaning cookies travel only within your own origin. Cross-origin cookie calls need credentials set to include, and XMLHttpRequest needs withCredentials set true. The moment credentials travel, the wildcard becomes illegal: the browser rejects any credentialed response whose allowed origin is *. The server must echo your exact origin and add Access-Control-Allow-Credentials: true. Miss either half and the browser blocks the read.
This pairing is deliberately strict. A wildcard says any site on earth may read the response, while credentials attach the user's session to it. Allowing both would let a phishing page read your authenticated data with one fetch call. The browser refuses the combination to keep that hole shut, which is why credentialed CORS demands explicit origins.
A subtler trap is reflecting arbitrary origins. Echoing whatever Origin arrives without validation feels like a fix, but it grants every malicious site full read access with credentials. Validate against a fixed allowlist of your own frontends, reject everything else with no CORS headers, and log rejected origins so you notice probing early.
Why curl Succeeds While Your Browser Fails
curl, Postman, server-side code, and mobile apps never implement the same-origin policy, so they never produce CORS errors. They send the request and show you the raw response, headers and all. That makes them excellent for testing APIs and terrible for testing CORS. A green curl result proves the endpoint works; it says nothing about whether a browser will let your page read the answer.
The mismatch misleads because the failure lives in the browser, not the server. Your Express route returns JSON with a 200, curl prints it, and you conclude the backend is innocent. Meanwhile the response lacks Access-Control-Allow-Origin, so every browser discards it. Both observations are true at once: the server responds correctly and the browser blocks correctly. The bug is the missing permission header between them.
To make curl useful, force it to act like a browser by attaching an origin: curl with an Origin header plus verbose output shows exactly which Access-Control headers return. For preflights, send OPTIONS with Access-Control-Request-Method and Access-Control-Request-Headers and watch the status and Allow headers. Those two commands reproduce any browser failure from a terminal, which matters when the failing client is a customer's laptop you can't inspect.
Treat the browser as the source of truth and curl as the lab instrument. Confirm the failure in DevTools Network, reproduce it with origin-carrying curl, fix the server, then verify in both. Teams that skip the curl repro end up screen-sharing with users instead of testing headers themselves.
Server Fixes in Express, Spring, Nginx, and Django
Every fix below does the same thing: return the right headers for your origins. In Express, the cors middleware handles preflights and echoes origins when you pass a validation function plus credentials true. The function form matters: a static string can't serve several frontends, and a wildcard breaks credentialed calls. Place the middleware before auth and body parsing so OPTIONS gets answered before anything else can reject it.
In Spring, annotate the controller with CrossOrigin and name your origins, methods, and headers explicitly: origins set to your frontend, allowedHeaders including Authorization and Content-Type, allowCredentials set to true. The annotation defaults are permissive in the wrong ways, so spell every attribute out. For global coverage, register the same mapping once in a WebMvcConfigurer instead of annotating each controller.
In Nginx, add the headers in the location block serving the API and use the always flag so error responses keep them: Access-Control-Allow-Origin with your origin, Access-Control-Allow-Credentials true, and Vary Origin. Answer OPTIONS with a 204 and the Allow-Methods and Allow-Headers lines, then return before proxying. Without always, every 4xx and 5xx leaves headerless and the browser reports CORS instead of the real status.
In Django, install django-cors-headers, put its middleware first, and set CORS_ALLOWED_ORIGINS to your frontend list with CORS_ALLOW_CREDENTIALS true. The defaults deny everything, which surprises teams that install the package and change nothing. Middleware order decides everything here too: if auth or error handling runs first, preflights die before CORS headers attach.
Debugging CORS With DevTools Network and Console
Start in the console, finish in the Network tab. The console message names the failed check: a missing Allow-Origin, a disallowed method, a rejected header, or a wildcard paired with credentials. That sentence tells you which header to hunt. But the console never shows header values, so click the failed request in the Network tab and read its Response Headers directly. The missing or wrong line is usually obvious within seconds.
For preflighted calls, look one row above the failed request for the OPTIONS call. Its status and response headers tell the preflight story: 401 means auth blocked it, 404 means no route handles OPTIONS, 200 without Allow headers means the CORS layer never ran. If no OPTIONS row exists, the request was simple and the failure is purely about the response's Allow-Origin value.
Status codes deserve a second look because the browser hides them. When the server returns 500 without CORS headers, the console reports a CORS error and the Network tab may show the request as failed, burying the real 500. Check server logs for the same timestamp before trusting the CORS label. More than one team has tuned CORS for an hour while the database was down.
Finish by verifying the fix in a fresh profile or with cache disabled. Preflight caching means a corrected server can still look broken until the old negative result expires. A clean reload takes seconds and removes the one variable that makes fixed bugs look alive.
Sale-Night Checkout Failures That Wore a CORS Disguise for 38 Minutes
- Test CORS on error responses, not just the happy path. A missing always flag turns every backend failure into a misleading browser error that sends debugging in the wrong direction.
- Rate limits need load-shaped staging tests. A threshold nobody ever trips in staging is a threshold you'll discover in production at the worst hour.
- Monitor the headers, not just the status codes. A 5-minute synthetic check against an error endpoint would have caught this the day it deployed.
| File | Command / Code | Purpose |
|---|---|---|
| origin-check.js | async function checkSameOrigin() { | Same-Origin Policy Blocks the Read, Never the Request |
| preflight-post.js | async function createItem() { | Preflight OPTIONS Requests Ask Permission Before Sending Dat |
| credentialed-fetch.js | async function loadProfile() { | Allowed Origins, Wildcards, and the Credentials Trap |
| server.js | const express = require('express'); | Server Fixes in Express, Spring, Nginx, and Django |
| cors-diagnose.js | async function diagnose(url) { | Debugging CORS With DevTools Network and Console |
Key takeaways
Common mistakes to avoid
6 patternsSetting Access-Control-Allow-Origin: * while sending credentials
Protecting the OPTIONS preflight with authentication
Using Nginx add_header without the always flag
Forgetting Access-Control-Allow-Headers for custom headers
Debugging CORS with plain curl and no Origin header
Leaving fetch credentials at the default while the API needs cookies
Interview Questions on This Topic
What does the same-origin policy protect, and how does CORS relax it?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Advanced JS. Mark it forged?
6 min read · try the examples if you haven't