DEV Community

Cover image for What Is CORS? Why Can't a Website Just Read Another Website's Data?
Aditya Sharma
Aditya Sharma

Posted on

What Is CORS? Why Can't a Website Just Read Another Website's Data?

Here's a piece of browser behavior that surprises most developers the first time they run into it.

A JavaScript application on app.example.com calls an API on api.otherservice.com. The request goes out over the network. The server processes it and sends back a response. Open the network tab and the response is sitting right there: status 200, headers, a full body. But the JavaScript that made the call can't read any of it. The browser throws an error, and the application never sees the data.

The request worked. The server responded. So what is the browser actually blocking?

Answering that precisely is what makes CORS make sense, not as a rule to memorize, but as a direct consequence of how browsers are built to behave by default.


The Same-Origin Policy

Before CORS, there's a more fundamental browser rule underneath it: the Same-Origin Policy. It states that JavaScript running on one origin cannot read data from a different origin unless something explicitly grants an exception.

An origin is defined as the exact combination of three things: scheme, host, and port. All three have to match for two URLs to count as the same origin.

https://app.example.com:443
https://api.example.com:443   → different origin (host differs)
http://app.example.com:443    → different origin (scheme differs)
https://app.example.com:8443  → different origin (port differs)
Enter fullscreen mode Exit fullscreen mode

app.example.com and api.example.com are different origins even though they're both subdomains of the same company's domain. The browser has no concept of "related" domains. It runs a strict string and port comparison against three fields. Nothing more.

Why the Browser Enforces This

It's easy to dismiss this as generic security caution, but the threat model behind it is specific and worth tracing through.

Say you're logged into your bank in one tab. Your browser holds a session cookie scoped to your bank's domain. In another tab, you load some unrelated site, a blog, a forum, anything. That page runs JavaScript, as most pages do.

If the Same-Origin Policy didn't exist, that JavaScript could issue a request to your bank's API from your browser. The browser would attach the bank's session cookie automatically, because cookies are scoped to the domain that set them, not to which tab currently has focus. The bank's server sees what looks like a normal authenticated request and returns your account data. The unrelated page's JavaScript is now free to read that response.

Notice what the attacker didn't need: no stolen password, no bypassed login, no exploited server bug. The browser did the authentication on the attacker's behalf simply by attaching the cookie. The only control standing between a malicious script and your account data is whether the browser lets that script read the response.

That's the exact gap the Same-Origin Policy closes.

Sending a Request vs. Reading the Response

This is the distinction that gets misstated constantly, so it's worth being exact about it.

CORS does not stop the browser from sending a cross-origin request. In most cases the request still goes out over the wire, the server still receives it, and the server still processes it and can still cause side effects. What CORS controls is a narrower thing: whether the browser hands the response back to the calling JavaScript.

fetch("https://api.otherservice.com/data")
Enter fullscreen mode Exit fullscreen mode

That request leaves the browser regardless of any CORS policy. If it fails, it fails at the point where the browser evaluates the response, not before the request is sent. This is why CORS is not a server-side firewall and not a mechanism for deciding which requests reach your infrastructure. It's a client-side gate on data flowing back into application code.

What CORS Actually Is

CORS, Cross-Origin Resource Sharing, is the protocol by which a server tells the browser which origins are permitted to read its responses. The server states a policy in its response headers. The browser is the one that reads those headers and enforces the decision. The server has no way to enforce this itself; there's no mechanism on the server side that prevents a non-browser client from reading whatever it wants.

Browser
   ↓
JavaScript on Origin A
   ↓  sends request
Server B
   ↓  processes request, returns response + CORS headers
Browser
   ↓  compares headers against Origin A
   ├── headers permit Origin A → response passed to JavaScript
   └── headers don't permit Origin A → response discarded, JS gets an error
Enter fullscreen mode Exit fullscreen mode

Server B does its job either way. The gate sits entirely on the browser's side of that last arrow.

Access-Control-Allow-Origin

The header doing the actual work is Access-Control-Allow-Origin. If api.otherservice.com wants to let app.example.com's JavaScript read its responses, its response includes:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"balance": 4200}
Enter fullscreen mode Exit fullscreen mode

The browser takes the value of that header and compares it, exactly, against the origin of the page that made the request. A match releases the response to JavaScript. Anything else, including a near-match like the wrong scheme or port, and the browser withholds it.

Access-Control-Allow-Origin: * tells the browser any origin may read the response. That's a reasonable setting for something like a public, unauthenticated weather API, where there's no session state to leak. It becomes a liability the moment credentials enter the picture, which is exactly where the spec adds a restriction.

Credentials Are a Separate Decision

By default, cross-origin requests made through fetch don't include cookies. For a request to carry the user's session, the client has to opt in explicitly:

fetch("https://api.otherservice.com/account", {
  credentials: "include"
});
Enter fullscreen mode Exit fullscreen mode

For the browser to hand back the response to a credentialed request, the server has to opt in on its side too, with Access-Control-Allow-Credentials: true. And once credentials are in play, the spec forbids pairing that with a wildcard origin. The server must name the exact origin it trusts:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

This restriction exists because Allow-Origin: * plus Allow-Credentials: true would mean "any website on the internet may read authenticated responses made with this user's cookies," which collapses the entire threat model the Same-Origin Policy was built to prevent. Allowing an origin and allowing credentials are deliberately kept as two separate checks the server has to pass.

Preflight: The Browser Checking Before It Commits

For some requests, the browser doesn't wait until the response comes back to decide anything. It sends a preliminary OPTIONS request first, called a preflight, before the actual request is sent at all.

The reasoning: some requests carry enough risk, a DELETE call, a request with a custom header, a non-standard content type, that the browser wants explicit permission before it commits to sending the real thing. So it asks first.

OPTIONS /account HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: content-type
Enter fullscreen mode Exit fullscreen mode

The browser is effectively asking: if I sent a DELETE with this header, from this origin, would you accept it? The server answers with its own set of headers describing exactly what it permits:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: content-type
Enter fullscreen mode Exit fullscreen mode

Only if the browser is satisfied that the real request falls within what the server just permitted does it go ahead and send it. If it doesn't, the browser stops there. The actual DELETE never leaves the client.

Not every cross-origin request goes through this. Browsers classify requests as "simple" when they're limited to GET, POST, or HEAD, use only a small allowed set of headers, and, for POST, use one of a few plain content types like application/x-www-form-urlencoded or text/plain. Simple requests skip the preflight and go straight out, with the browser checking the response headers afterward instead of asking first. Anything outside that narrow set, a custom Authorization header, a PUT, a JSON body sent as application/json, triggers the preflight. The rule set is a list of specific conditions, but the underlying idea is what matters: preflight is the browser de-risking a request before it happens, not a check applied uniformly to everything.

"CORS Protects My API" Is a Misconception

CORS is a browser mechanism. It has no meaning outside a browser context. curl doesn't check Access-Control-Allow-Origin. A Python script making an HTTP request doesn't check it. Another server calling your API directly doesn't check it. None of these clients run your JavaScript, so there's no response-reading step for CORS to gate in the first place. They send the request and read whatever comes back, full stop.

That means CORS is not authentication, not authorization, and not a network-level access control. Your API still needs to independently verify who's calling it and what they're allowed to do, regardless of what your CORS headers say. CORS governs exactly one thing: whether a browser will let JavaScript running on a given origin read the response.

CORS Is Not CSRF Protection

We've covered CSRF in a previous VickyBytes article, so it's worth being precise about how the two relate, because they're conflated often.

CSRF works by getting the victim's browser to send a request it didn't intend to send, a form submission, a fetch call, triggered by a malicious page while the victim happens to be authenticated elsewhere. The browser attaches the session cookie automatically, the request lands on the real server, and if there's no CSRF protection, the server acts on it. The attack doesn't require reading the response. "Change the account's email address" or "transfer funds" is the entire attack; the attacker never needs to see what comes back.

CORS doesn't intervene here at all. It doesn't stop the request from being sent, and it doesn't stop the server from processing it. It only controls whether the malicious page's JavaScript can read the response, and in most CSRF attacks, the attacker was never trying to read the response in the first place.

CSRF has to be defended against on the server: CSRF tokens, SameSite cookie attributes, origin verification on state-changing endpoints. CORS headers don't substitute for any of that.

Common Misconfigurations

A few patterns keep showing up in real codebases:

Reflecting the Origin header back unchecked. Some backends read whatever Origin value the request claims and echo it directly into Access-Control-Allow-Origin. It looks like an allowlist check but functions identically to a wildcard, while giving the impression that origin validation is happening.

Pairing broad origins with credentials. Setting Access-Control-Allow-Credentials: true alongside a loosely validated or reflected origin recreates the exact scenario the credentials restriction exists to prevent.

Treating CORS as authorization logic. Configuring CORS correctly says nothing about whether the calling user is allowed to perform the action they're requesting. That check belongs entirely in the application layer.

Mistaking a passed preflight for a trusted caller. A successful preflight only describes what the browser is permitted to send next. It carries no information about who the eventual caller is or whether their credentials are valid.

Defenses

Use an explicit origin allowlist instead of reflecting the request's Origin header.

const allowedOrigins = ["https://app.example.com"];
if (allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
}
Enter fullscreen mode Exit fullscreen mode

Validating against a known list, rather than trusting whatever the client sends, closes the reflection pattern above.

Scope Allow-Methods and Allow-Headers to what the endpoint actually needs, rather than permitting every method and header by default. A narrower policy shrinks what a misconfigured or compromised client-side origin could do even in the worst case.

Treat credentialed endpoints as a distinct, more sensitive configuration. Never let a credentialed route inherit a permissive origin policy written for public, unauthenticated endpoints.

Keep authentication and authorization entirely independent of CORS. CORS headers should never be the mechanism standing in for either check.

Implement CSRF defenses on their own terms. SameSite cookies and CSRF tokens address a threat model CORS was never built to cover.


CORS isn't a rule that says one server can't talk to another. Servers talk to each other over HTTP constantly with no CORS headers involved at all; CORS has no meaning outside a browser.

What CORS actually is: the browser asking a server, on behalf of the JavaScript running in a specific tab, whether that origin should be trusted with the response. The server answers with headers. The browser decides whether to honor them. Everything else, the request being sent, the server processing it, happens regardless of what those headers say.

The gap between "the request was sent" and "the response was readable" is exactly the gap CORS exists to control.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.