Every web developer has stared at this bright red error in their browser console:
Access to fetch at 'https://api.backend.com/users' from origin 'https://my-app.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
When this happens, the most common developer reaction is: "Great, my backend has a security layer that blocks unauthorized clients."
That assumption is completely wrong.
CORS (Cross-Origin Resource Sharing) does not protect your server. In fact, if an attacker wants to scrape your database, exploit an unauthenticated endpoint, or flood your API, CORS will not stop them for a single millisecond.
Worse still: under standard conditions, when your browser throws a CORS error, your server may have already processed the request, written rows to your database, and returned a 200 OK. The browser simply hid the response from your JavaScript code.
Let us pull back the curtain on how CORS and the Same-Origin Policy actually work, what happens inside the browser network stack, and why relying on CORS for backend protection is a recipe for disaster.
1. Who Does CORS Actually Protect?
To understand CORS, you must first understand the Same-Origin Policy (SOP).
The Same-Origin Policy is a sandbox boundary enforced exclusively inside web browsers (Chrome, Firefox, Safari, Edge). It states:
A script running on Origin A (
https://evil.com) cannot read private data returned from Origin B (https://bank.com).
Why does this rule exist? Because browsers send ambient credentials automatically. If you are logged into your bank account, your browser holds your session cookies. If you open a tab with a malicious website https://evil-site.com, that page's JavaScript could execute:
fetch('https://bank.com/api/account-balance')
.then(res => res.json())
.then(data => sendToAttackerServer(data));
If the Same-Origin Policy did not exist, the browser would attach your valid bank.com session cookies, the bank would return your balance, and the malicious script would steal your financial data.
The Same-Origin Policy exists to protect the user's browser, not your API server.
CORS is simply a controlled escape hatch. It is a set of HTTP response headers that allows bank.com to tell the browser: "It is safe to let https://trusted-partner.com read this specific JSON payload."
2. The Danger: Simple Requests Execute on Your Server Anyway
Here is the most dangerous misconception in web development: developers assume that a CORS error means the request was blocked before reaching the backend.
That is only true for some requests. For many requests, the browser sends the payload first and asks questions later.
The Fetch specification divides cross-origin requests into two categories: Simple Requests and Preflighted Requests.
What Makes a Request "Simple"?
A request is classified as "Simple" if it meets all of these criteria:
- HTTP Method is
GET,HEAD, orPOST. - Headers are restricted to CORS-safelisted headers (
Accept,Accept-Language,Content-Language,Content-Type). - If
Content-Typeis set, its value must be one of:application/x-www-form-urlencodedmultipart/form-datatext/plain
Why are these exempted? Because traditional HTML forms (<form method="POST" action="https://bank.com/transfer">) have been able to make cross-origin requests since the 1990s.
What Actually Happens During a Simple Request
When your frontend runs a simple cross-origin fetch():
[Browser Frontend (origin: evil.com)]
|
| 1. POST /api/delete-account (Origin: https://evil.com)
v
[Backend Server (api.example.com)]
|
| 2. Server executes delete logic, commits DB transaction
| 3. Returns 200 OK (body: {"status": "deleted"})
v
[Browser Network Layer]
|
| 4. Inspects response: Missing 'Access-Control-Allow-Origin: https://evil.com'
| 5. Drops response body, emits red console error
v
[Frontend JS catch block: TypeError: Failed to fetch]
- The browser opens a TCP/TLS connection to
https://api.example.com. - The browser sends the complete HTTP
POSTrequest with theOrigin: https://evil.comheader. - Your backend server receives the request, routes it to your handler, updates the database, or deletes a record.
- Your server responds with
HTTP 200 OKand a JSON response body. - The browser network engine receives the response and checks for the header
Access-Control-Allow-Origin. - Seeing no matching header, the browser refuses to hand the response body to your JavaScript code and triggers a
TypeError: Failed to fetch.
Your backend executed the command. The database was modified. The attacker did not get to read the JSON response, but the mutation happened.
3. Preflight Requests: When OPTIONS Steps In
If your request does not meet the "Simple" criteria, the browser will not risk sending the payload immediately. This includes requests with:
- HTTP methods like
PUT,DELETE,PATCH. - A
Content-Typeheader ofapplication/json. - Custom authentication headers like
Authorization: Bearer tokenorX-API-Key.
In these cases, the browser initiates a Preflight Check using the HTTP OPTIONS method.
The Preflight Flow
Browser Server
| |
|---- 1. OPTIONS /api/data -------------------->|
| Origin: https://app.com |
| Access-Control-Request-Method: DELETE |
| Access-Control-Request-Headers: auth |
| |
|<--- 2. HTTP 204 No Content -------------------|
| Access-Control-Allow-Origin: https://app.com
| Access-Control-Allow-Methods: DELETE |
| Access-Control-Allow-Headers: auth |
| Access-Control-Max-Age: 86400 |
| |
|---- 3. DELETE /api/data --------------------->| (Actual Request)
|<--- 4. HTTP 200 OK (Data Deleted) ------------|
-
The Probe: The browser issues an
OPTIONSrequest asking: "I want to send a DELETE request with an Authorization header from origin https://app.com. Will you accept it?" -
The Decision:
- If the server returns a 2xx status code with matching
Access-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers, the preflight succeeds. - If the preflight fails (non-2xx response or missing headers), the browser cancels the transaction immediately. The actual
DELETErequest is never transmitted.
- If the server returns a 2xx status code with matching
-
The Execution: If approved, the browser sends the actual
DELETErequest and gives the response to JavaScript.
Reducing Preflight Latency Overhead
Sending two HTTP round trips for every API call adds latency, especially on mobile networks. You can tell browsers to cache the preflight approval using the Access-Control-Max-Age header:
Access-Control-Max-Age: 86400
This tells the browser: "Do not send another OPTIONS probe for this exact endpoint and method combination for the next 24 hours." Note that Chromium caps this value at 7,200 seconds (2 hours), while Firefox supports up to 86,400 seconds (24 hours).
4. Why curl and Postman Never Get CORS Errors
If you copy the exact URL that failed with a CORS error in Chrome and run it in your terminal:
curl -X POST https://api.backend.com/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice"}'
It works seamlessly without any errors. Why?
Because curl, Postman, Python requests, and backend servers are not web browsers. They do not implement the Same-Origin Policy sandbox.
The Origin header and CORS negotiation rules are entirely client-side agreements followed by browsers to protect end users. A script running outside a browser has no user session to isolate and no SOP sandbox to respect.
If your server relies on CORS to restrict access, an attacker does not need to bypass anything. They just run their script outside a browser.
5. Three Common CORS Security Traps
Trap 1: Reflecting the Origin Header Dynamically
When developers get tired of CORS errors while building multi-tenant or multi-domain apps, they sometimes write middleware like this:
// DANGEROUS CODE - DO NOT USE
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", req.headers.origin);
res.header("Access-Control-Allow-Credentials", "true");
next();
});
This blindly trusts whatever Origin the incoming browser sent and reflects it back with credentials enabled. Any malicious site on the internet can now issue authenticated requests through a victim's logged-in browser and read confidential responses.
If you support multiple origins, validate against an explicit whitelist:
const ALLOWED_ORIGINS = new Set([
"https://dashboard.example.com",
"https://admin.example.com"
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Access-Control-Allow-Credentials", "true");
}
next();
});
Trap 2: The Wildcard + Credentials Crash
The Fetch specification explicitly forbids combining wildcard origins with credential sharing:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
If a server responds with both of these headers simultaneously, modern browsers will reject the response and block JavaScript from reading it. If you want credentials: 'include' (cookies or authorization headers), you must specify an explicit origin, never *.
Trap 3: Backend Crashes Masked as CORS Errors
Have you ever seen an endpoint that usually works suddenly fail with a CORS error in production?
When your backend throws an unhandled 500 internal server error or a reverse proxy (like Nginx) returns a 502 Bad Gateway, error handlers often bypass your standard CORS middleware.
Because the error response lacks Access-Control-Allow-Origin, the browser reports a CORS failure rather than showing the real 500 error status code. Always attach CORS headers at the edge reverse proxy or in global error-handling middleware.
6. If CORS Isn't Server Security, What Is?
To truly secure your backend endpoints against unauthorized access and malicious cross-site abuse, use server-side defenses:
1. Robust Authentication (Bearer Tokens or Explicit Sessions)
Do not rely on network origin. Require cryptographic verification on every request via Authorization: Bearer token or securely managed server-side sessions.
2. Cookie Hardening against CSRF
If you use cookies for authentication:
- Set
SameSite=LaxorSameSite=Stricton session cookies so browsers do not attach them on cross-site requests. - Mark them
HttpOnly; Secure. - Use Anti-CSRF tokens for state-changing endpoints if cross-site form submissions are possible.
3. Fetch Metadata Request Headers (Sec-Fetch-*)
Modern browsers automatically attach tamper-proof Sec-Fetch-* headers to all outgoing requests. Your server can inspect these to immediately reject unwanted cross-origin requests:
app.use((req, res, next) => {
const site = req.headers['sec-fetch-site'];
// Block cross-site state mutations on sensitive API endpoints
if (site === 'cross-site' && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
return res.status(403).json({ error: 'Cross-site mutation rejected.' });
}
next();
});
Unlike CORS, Sec-Fetch-Site is evaluated on the server before executing business logic, preventing even simple cross-origin request side effects.
Summary Cheat Sheet
| Mechanism | Where It Executes | What It Protects | Does It Block curl/Postman? |
|---|---|---|---|
| Same-Origin Policy | Browser client | The end user and their session | No |
| CORS | Browser client | Relaxes SOP for allowed origins | No |
Preflight (OPTIONS) |
Browser & Server | Prevents non-simple requests from firing early | No |
| Authentication / CSRF | Backend Server | Your database, business logic, and APIs | Yes |
CORS is not a shield for your backend. It is a permission slip for the browser. Once you understand that distinction, troubleshooting frontend networking errors and designing secure APIs becomes dramatically easier.
Top comments (0)