Don't like ads? Go Ad-Free Today

CORS Explained Why Your Fetch Works in curl but Explodes in the Browser

Updated on

CORS errors feel like server bugs. They are not. They are browser-enforced permission checks. Understand the Same-Origin Policy, preflight requests, and the five headers that matter — and fix every CORS error in under 5 minutes.

CORS Explained: Why Your Fetch Works in curl but Explodes in the Browser 1
ADVERTISEMENT · REMOVE?

You write a fetch() call. It works perfectly in curl. You open the browser, hit the endpoint, and get slapped with:

Access to fetch at 'https://api.example.com/data' from origin 'https://yourapp.com' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present 
on the requested resource.

You Google it. Stack Overflow tells you to add a header. You add the header. Different error. You add another header. Now it’s a preflight error. Two hours in, you’re disabling CORS entirely in Chrome with a flag you found in a 2016 answer. You ship it to production like that and hope nobody notices.

This article exists to stop that from happening. CORS takes about 10 minutes to understand correctly, and once you do, you fix it in 2 minutes every time.

It’s Not a Server Bug. It’s a Browser Rule.

This is the single most important thing to understand about CORS: it is enforced entirely by the browser. The server doesn’t block your request. The browser does, after the server already responded.

That’s why curl works. curl doesn’t enforce CORS. Neither does Postman. Neither does your backend calling another backend. CORS only activates when a browser makes a cross-origin request on behalf of a web page.

The server’s job is to say whether it permits cross-origin access via response headers. The browser’s job is to check those headers and decide whether to hand the response to your JavaScript. If the headers aren’t right, the browser discards the response and throws the error — even though the request completed and the server returned data.

Why the Browser Does This (The Same-Origin Policy)

The Same-Origin Policy (SOP) is a browser security rule that prevents JavaScript running on https://evil.com from reading responses from https://yourbank.com. Without it, any site could silently make authenticated requests on your behalf and read the results.

Two URLs have the same origin if their scheme, host, and port all match:

  • https://app.example.com and https://api.example.com — different origins (different subdomain)
  • http://example.com and https://example.com — different origins (different scheme)
  • https://example.com and https://example.com:8080 — different origins (different port)
  • https://example.com/foo and https://example.com/bar — same origin (path doesn’t count)

CORS is how servers opt in to relaxing SOP for specific origins. Not a bypass — an explicit permission grant.

Simple Requests vs. Preflight Requests

Not every cross-origin request triggers a preflight. The browser splits requests into two categories.

Simple requests go straight through (no OPTIONS check first). A request is “simple” when it meets all of:

  • Method is GET, POST, or HEAD
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No custom headers beyond the CORS-safelisted list

Preflighted requests are everything else — any PUT/PATCH/DELETE, any application/json body, any custom header like Authorization or X-API-Key. Before the actual request, the browser automatically sends an OPTIONS request to ask “do you allow this?”

What a Preflight Actually Looks Like

Here’s a real preflight exchange. Your frontend calls fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' } }).

The browser first sends this OPTIONS request automatically — you never write this code:

OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://yourapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

The server must respond with headers confirming it allows this:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Only if the preflight succeeds does the browser send the actual POST. If any preflight header is missing or wrong, the real request never leaves the browser.

Access-Control-Max-Age: 86400 caches the preflight result for 24 hours so the browser doesn’t repeat this handshake on every request. Worth setting it.

The Headers That Actually Matter

Access-Control-Allow-Origin — the only required header. Either a specific origin (https://yourapp.com) or * for any origin. You cannot use * when sending credentials (cookies, Authorization headers).

Access-Control-Allow-Methods — required for preflighted requests. List every method you want to permit. Always include OPTIONS itself or some servers return 405 on the preflight.

Access-Control-Allow-Headers — required when the request includes custom headers. Must explicitly list every non-safelisted header your frontend sends. Missing Authorization here is responsible for roughly 40% of CORS support tickets.

Access-Control-Allow-Credentials: true — required only when you’re sending cookies or HTTP auth. Must also set credentials: 'include' on the fetch call. When this is true, Allow-Origin must be an explicit origin — * won’t work and you’ll get a second distinct error.

Access-Control-Expose-Headers — response headers your frontend can read. By default, JavaScript can only access a small safelisted set (Content-Type, Cache-Control, etc.). If you need to read a custom header like X-Request-Id in your response handler, add it here.

Five Mistakes That Cost Hours

1. Wildcard with credentials. Setting Access-Control-Allow-Origin: * and then wondering why credentialed requests still fail. The spec explicitly forbids wildcards when credentials are involved. You must reflect the specific origin: read the Origin request header and echo it back in the response.

2. Not handling OPTIONS at all. Express, FastAPI, and most frameworks don’t automatically respond to OPTIONS requests with CORS headers. If your CORS middleware only runs on actual endpoints and not on the preflight route, every credentialed or non-simple request will fail. The fix: make sure your CORS middleware runs before your router, or handle OPTIONS explicitly.

3. Returning CORS headers only on success. If your server returns a 4xx or 5xx without CORS headers, the browser hides the real status code from JavaScript. Your error handler receives a generic network error with no status. Always return Access-Control-Allow-Origin on every response, not just 200s.

4. Forgetting the port. https://app.example.com and https://app.example.com:3000 are different origins. During local development you’ll frequently hit a backend on port 8080 from a frontend on 3000. Both ports must be in your allowed origins list, or you need a dev proxy.

5. Caching a bad preflight. Access-Control-Max-Age caches the preflight. If you set it to 86400 and then update your allowed headers, browsers with the cached (wrong) preflight will keep failing for up to 24 hours. During development, either set this to 0 or clear it with a hard reload + DevTools cache disable.

Quick Fixes by Scenario

Frontend + backend on different domains, no cookies: Add Access-Control-Allow-Origin: * (or the specific frontend origin) to your server. Done.

Frontend + backend on different domains, with cookies/auth: You need both Access-Control-Allow-Origin: https://yourfrontend.com (explicit, no wildcard) and Access-Control-Allow-Credentials: true on the server, plus credentials: 'include' in your fetch call. Cookies also need SameSite=None; Secure to cross origins.

Local dev hitting a remote API you don’t control: Add a dev proxy in your build tool. Vite: server.proxy. webpack-dev-server: devServer.proxy. Next.js: rewrites. The proxy makes your browser think all requests are same-origin; CORS never triggers.

API Gateway / CDN in front: Make sure the OPTIONS preflight isn’t getting swallowed before it reaches your origin. AWS API Gateway and CloudFront both have CORS settings that can conflict with your app-level headers — if you set headers in both places you’ll often get duplicated header values, which also fails.

If you need to verify the exact headers your server is returning, the CORS Headers Builder & Validator on IO Tools lets you construct and inspect the full header set without writing a single line of code. Useful for auditing what a backend actually returns vs. what you think it returns.

CORS errors look like server bugs because the error message mentions the server. They’re browser-enforced permission checks — and once you know which header is missing and why, every CORS error resolves in minutes. Build the right headers with the CORS Headers Builder or test your raw request structure with the cURL Command Builder to rule out client-side mistakes before touching server config.

Want To enjoy an ad-free experience? Go Ad-Free Today

Install Our Extensions

Add IO tools to your favorite browser for instant access and faster searching

Add to Chrome Extension Add to Edge Extension Add to Firefox Extension Add to Opera Extension

Scoreboard Has Arrived!

Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!

ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?

News Corner w/ Tech Highlights

Get Involved

Help us continue providing valuable free tools

Buy me a coffee
ADVERTISEMENT · REMOVE?