April 16, 2026
· 9 min readCORS Isn't Blocking You — Your Browser Is. Here's What's Actually Happening
Most developers treat CORS errors as a server configuration mystery. They're not. Your server responded fine — your browser intercepted the response. This post covers the CSRF attack CORS was built to prevent, how origin is actually defined, why Postman never sees CORS errors, how preflight requests work, and what every response header actually means.

You've seen this error. The move: Google it, find the answer, paste some headers in, see if it goes away. Or the newer move: ask the AI, copy the four headers it hands you, paste them in. Maybe it worked. Maybe you added four headers and removed two and you're now afraid to touch it in case it breaks again.
But what's actually happening? In most cases, the server got your request. It responded. The data was ready. Your browser is the one blocking you from reading it. And for many modern API calls, your browser is sending a completely separate HTTP request before your actual one — one you never wrote, that doesn't appear anywhere in your code. If your server doesn't handle it, your real request never goes out.
This post covers why CORS exists, how it actually works, and how to diagnose issues instead of pasting headers until something changes.
TL;DR
- CORS is a browser enforcement mechanism, not a standalone security system. The server declares what it allows; the browser enforces it.
- An origin = protocol + hostname + port. All three.
localhost:3000andlocalhost:8000are different origins. - Postman has no CORS because it's not a browser. It's a direct HTTP client. These are separate questions.
- Simple requests go through immediately. Preflighted requests trigger an OPTIONS check first.
Content-Type: application/jsonandAuthorizationboth trigger a preflight.- If the preflight returns 404, your real request never fires — and the console just says "CORS error".
- You cannot fix CORS from the client side. The policy lives on the server.
Why CORS Exists: The CSRF Attack
To understand CORS you need to understand the attack it was designed to contain: Cross-Site Request Forgery (CSRF).
Imagine you're logged into your bank. Session cookie is valid, sitting in the browser. You open another tab — any website. That page quietly runs this:
fetch("https://bank.com/transfer", {
method: "POST",
credentials: "include", // attach cookies automatically
body: JSON.stringify({ amount: 5000, to: "attacker" })
});The evil site can't read your bank cookie. But when it makes a request to bank.com, the browser automatically attaches your bank.com cookies to it. That's how cookies work — they travel with requests to their domain regardless of where the request originated.
The bank receives what looks like an authenticated request from you. Transfer goes through. You never clicked anything.
Modern browsers have made this harder, but the threat model still holds. Browsers established a rule: for many cross-origin requests, the browser protects the response. For more sensitive requests, there's a second layer that stops the request from leaving at all.
CORS didn't create the restriction. It created the exception. When a server sends the right CORS headers, it's explicitly saying: "I know this origin. I trust it. Let the response through."
The attacker can't fake that. They can fire cross-origin requests, but they cannot forge the server's response headers. The browser goes to the real server to ask. Only the real server can reply.
Origin Is Not Just a Domain
Most developers think origin means domain. It doesn't.
An origin is three things: protocol + hostname + port. All three must match for requests to be considered same-origin.
| URL A | URL B | Same Origin? | Reason |
|---|---|---|---|
https://example.com |
http://example.com |
❌ | Different protocol |
https://app.example.com |
https://api.example.com |
❌ | Different subdomain |
https://example.com |
https://example.com:8080 |
❌ | Different port |
https://example.com/users |
https://example.com/posts |
✅ | Same protocol, host, port |
http://localhost:3000 |
http://localhost:8000 |
❌ | Different port |
The path is completely irrelevant. The browser does not care about family resemblance between subdomains. It applies the same rules it would between two completely unrelated websites.
This is why your first CORS error almost always happens in local development. Frontend on port 3000, backend on port 8000. Same machine. Different origins. A developer makes the exact call in Postman — it works. Same call in the browser — CORS error. Nothing changed. That's intended behavior.
Why Postman Never Has CORS Errors
This deserves its own section because it causes genuine confusion.
Postman's desktop app makes HTTP calls directly to the server. No browser. No concept of an origin. No user session to hijack. No idea what other tabs you have open. Same as curl, same as wget. They're just programs talking to servers.
CORS is a browser feature because that's the only environment where untrusted code from the internet runs alongside your authenticated sessions. The CSRF attack needs three things: a browser, a live authenticated session, and untrusted code running in that same browser. Postman satisfies none of them.
⚠️ Warning: When debugging, Postman tells you whether the endpoint works. It tells you nothing about CORS. These are separate questions answered by separate tools.
Simple Requests vs Preflight Requests
The browser puts every cross-origin request into one of two categories.
Simple Requests
A request is "simple" when all three of the following hold:
- Method is
GET,HEAD, orPOST - Headers are from a small standard set (no
Authorization, no custom headers) - If
Content-Typeis used, it must beapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain
application/json is not on that list.
For simple requests, the browser sends the request directly. The server responds. If the right CORS header is present, JavaScript reads the response. One round trip.
💡 Tip: These constraints aren't arbitrary. They exactly match the kinds of requests HTML forms could always make since the early web — before any of this existed. The browser doesn't add friction there because that attack surface always existed and servers were always expected to handle it.
Preflight Requests
The moment you violate any of the simple request conditions, the browser inserts a permissions check. It sends an OPTIONS request to the same endpoint — one you never wrote, that doesn't appear in your code.
What triggers a preflight:
Content-Type: application/jsonAuthorizationheaderPUT,DELETE, orPATCHmethods- Any custom request header
If your server has no handler for OPTIONS, it returns a 404. Preflight fails. Real request never fires. The console reports a CORS error.
The console does not say your OPTIONS route returned a 404. It just says CORS error. Open the Network tab, find the OPTIONS request, check its status. That's your actual problem.
The CORS Response Headers — What They Actually Do
Access-Control-Allow-Origin
Present on every response. Answers the browser's core question: is this origin allowed to see what came back?
# Allow any origin — fine for genuinely public APIs
Access-Control-Allow-Origin: *
# Allow a specific origin
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin⚠️ Warning: You cannot comma-separate multiple origins. The spec allows one value or the wildcard. For multiple origins (production + staging), maintain an allowlist on the server, check the incoming
Originheader, and echo back the matching one. Always addVary: Originif you're doing this — without it, a CDN may cache the response for one origin and serve it to another. Hard to reproduce, hard to trace.
Access-Control-Allow-Methods
Preflight response only. Declares which HTTP methods the server accepts.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONSIf your actual request uses a method not listed, the preflight fails before your request goes out.
Access-Control-Allow-Headers
Declares which request headers the server accepts. This is the most common CORS misconfiguration after switching to JSON.
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-HeaderYou've probably hit this: GET works fine. You switch to POST with a JSON body, CORS error. You didn't change the server. You didn't change the response headers. Almost always the same cause — Content-Type: application/json wasn't declared in Allow-Headers. The server's capability to handle that header and its CORS declaration of it are entirely separate things.
Access-Control-Allow-Credentials
You're likely to encounter this when moving from JWT tokens to session cookies.
# Server side
Access-Control-Allow-Credentials: true
# Client side — both required
fetch(url, { credentials: "include" })By default, cross-origin requests don't include cookies or HTTP credentials. JWTs in Authorization headers worked. You switched to cookie-based sessions, it stopped. This header is why.
⚠️ Warning: When
Allow-Credentials: true, you cannot use*forAllow-Origin. The browser rejects that combination — it's in the spec and won't change. If any origin could make credentialed requests as your users, you're right back to the CSRF problem CORS was built to prevent.
Access-Control-Expose-Headers
The one you find out about after shipping.
Even after a CORS check passes and the response reaches your JavaScript, the browser controls which response headers your code can actually read. Only these are readable by default:
Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
Anything outside this list — custom headers, rate limit data, request IDs — returns null when your code tries to read it. No error. No warning. Just null.
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining, X-RateLimit-ResetCheck this before you ship.
The Full Header Picture
Debugging CORS Errors: The Actual Process
The console gives you a summary. The Network tab gives you evidence.
- Open the Network tab — not the console.
- Look for an OPTIONS request to the same endpoint. Did it fire?
- Check its status. If 404 → your server has no OPTIONS handler. That's your problem, not your headers.
- If preflight returned 200, check
Access-Control-Allow-Origin,Allow-Methods,Allow-Headersin the response. Do they match exactly what your request is sending? - If no preflight, check if
Access-Control-Allow-Originis present in the main response and exactly matches the request'sOrigin.
| Symptom | Most Likely Cause |
|---|---|
| OPTIONS returns 404 | Server has no OPTIONS route handler |
| OPTIONS returns 200 but request still fails | Allow-Origin missing or wrong value |
| GET works, POST with JSON fails | Content-Type not in Allow-Headers |
| JWT worked, cookies don't | Need Allow-Credentials: true + credentials: include |
Custom header reads as null |
Header not listed in Expose-Headers |
| Works for one user, fails for another | Vary: Origin missing, CDN serving cached header |
Production Checklist
- Set
Access-Control-Allow-Origindynamically for multiple trusted origins — maintain an allowlist, echo back the match, addVary: Origin. - Handle OPTIONS explicitly — don't rely on your framework's default catch-all. Respond with 200 or 204.
- List every custom request header in
Access-Control-Allow-Headers— Authorization, Content-Type, and anything else your client sends. - Set
Access-Control-Allow-Credentials: trueonly when you actually need cookies or HTTP credentials — and never combine with*. - Declare
Access-Control-Expose-Headersfor any custom response headers your frontend reads. - Cache preflight responses with
Access-Control-Max-Ageto reduce OPTIONS round trips in production. - Test from the browser, not Postman, for CORS-specific issues. They are separate tools answering separate questions.
Conclusion
CORS errors feel mysterious because the console is aggressively unhelpful. "No Access-Control-Allow-Origin header is present" doesn't mean your code is broken. It means the server hasn't told the browser what it allows, and the browser is faithfully enforcing the absence of a declaration.
Your browser is running code from hundreds of websites simultaneously on behalf of a user who is logged into their bank and their email. The same-origin policy is what keeps those contexts from colliding. CORS is how servers deliberately open a gap in that wall when they actually mean to.
Server declares. Browser enforces. Your job is to make sure those two are aligned — and to open the Network tab instead of the console when they're not.
FAQ
Why does my API work in Postman but throw a CORS error in the browser?
CORS is a browser-only feature. Postman makes raw HTTP calls with no concept of an origin, user session, or same-origin policy. The browser enforces CORS to protect authenticated sessions — Postman has none of those, so it skips the check entirely.
What exactly is an 'origin' in CORS?
Origin is three things combined: protocol, hostname, and port. https://app.example.com and http://app.example.com are different origins. localhost:3000 and localhost:8000 are different origins. Even on the same machine, the browser treats them as unrelated.
What triggers a CORS preflight request?
Any cross-origin request that uses a non-simple method (PUT, DELETE, PATCH), a non-standard header (Authorization, Content-Type: application/json), or both. The browser sends an OPTIONS request first to check if the server permits the real request.
Why can't I use a wildcard (*) with credentials?
The browser spec explicitly rejects the combination of Access-Control-Allow-Credentials: true and Access-Control-Allow-Origin: *. Allowing any origin to make credentialed requests defeats the same CSRF protection CORS was designed to enable.
How do I allow multiple specific origins in CORS?
The spec only allows one origin value or a wildcard in Access-Control-Allow-Origin. The correct pattern is to maintain an allowlist on the server, check the incoming Origin request header against it, and echo back the matching origin dynamically.
What is Access-Control-Expose-Headers and why does it matter?
Even after a CORS check passes, the browser only lets JavaScript read a small set of response headers by default. If you're setting custom headers like X-Request-Id or X-RateLimit-Remaining, your JavaScript will read null unless you explicitly list those headers in Access-Control-Expose-Headers.
How do I debug a CORS error properly?
Open the Network tab — not the console. Look for an OPTIONS request to the same endpoint. Check its response status and headers. If it returned 404, your server has no OPTIONS handler. If the headers are wrong or missing, fix those specifically. The console error message alone tells you almost nothing actionable.