DEV Community

Cover image for CORS Is Not Server Security: What Actually Happens When a Browser Blocks a Request
Syed Anzar
Syed Anzar

Posted on

CORS Is Not Server Security: What Actually Happens When a Browser Blocks a Request

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.
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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:

  1. HTTP Method is GET, HEAD, or POST.
  2. Headers are restricted to CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type).
  3. If Content-Type is set, its value must be one of:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/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]
Enter fullscreen mode Exit fullscreen mode
  1. The browser opens a TCP/TLS connection to https://api.example.com.
  2. The browser sends the complete HTTP POST request with the Origin: https://evil.com header.
  3. Your backend server receives the request, routes it to your handler, updates the database, or deletes a record.
  4. Your server responds with HTTP 200 OK and a JSON response body.
  5. The browser network engine receives the response and checks for the header Access-Control-Allow-Origin.
  6. 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-Type header of application/json.
  • Custom authentication headers like Authorization: Bearer token or X-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) ------------|
Enter fullscreen mode Exit fullscreen mode
  1. The Probe: The browser issues an OPTIONS request asking: "I want to send a DELETE request with an Authorization header from origin https://app.com. Will you accept it?"
  2. The Decision:
    • If the server returns a 2xx status code with matching Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers, the preflight succeeds.
    • If the preflight fails (non-2xx response or missing headers), the browser cancels the transaction immediately. The actual DELETE request is never transmitted.
  3. The Execution: If approved, the browser sends the actual DELETE request 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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=Lax or SameSite=Strict on 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();
});
Enter fullscreen mode Exit fullscreen mode

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)