Skip to content
pwnsy
web-securityintermediate#cors#web-security#same-origin-policy#api-security#appsec

CORS Explained: The Same-Origin Policy & How to Relax It

How the same-origin policy protects users, how CORS shares responses across origins, preflight and credentials, and how to configure it safely.

Open your bank in one tab and a stranger's website in another. The stranger's JavaScript cannot read your bank balance, even though both pages run in the same browser with access to the same cookies. The rule that guarantees this is the same-origin policy, and it is one of the load-bearing walls of web security. Cross-Origin Resource Sharing, CORS, is the controlled way to make a hole in that wall when you actually need one.

CORS confuses people because the browser reports its failures as errors on the page making the request, which makes it look like a bug in your frontend. It is a security boundary doing its job. Understanding what the boundary protects makes the header configuration obvious.

What an origin is

An origin is the combination of three things: the scheme, the host, and the port. Two URLs share an origin only if all three match. RFC 6454 defines this precisely, and the practical effect is stricter than most people guess.

URL compared to https://app.example.comSame origin?Why
https://app.example.com/dataYesPath does not affect origin
http://app.example.comNoDifferent scheme
https://api.example.comNoDifferent host
https://app.example.com:8443NoDifferent port
https://example.comNoDifferent host, subdomain counts

A different subdomain is a different origin. A different port is a different origin. This strictness is the point: it makes the boundary simple to reason about.

What the same-origin policy actually blocks

The policy is narrower than it first appears. It stops script on origin A from reading responses served by origin B. It does not stop the browser from sending a request to origin B, and it does not stop the browser from attaching origin B's cookies to that request.

That distinction matters. Your page can still submit a form to another origin, load an image from it, or run a script from it. What the page cannot do is read back the response text with JavaScript. So the policy protects the confidentiality of cross-origin responses. It does nothing to stop a cross-origin request from being sent with your credentials, which is precisely why cross-site request forgery is a separate class of bug with its own defenses.

How CORS relaxes it

When your frontend on one origin needs to read an API on another origin, the server declares that it consents. It does this with response headers the browser inspects before handing the response to your script. The central one is Access-Control-Allow-Origin. If your API returns:

Access-Control-Allow-Origin: https://app.example.com

then a page on https://app.example.com is allowed to read that response. A page on any other origin is refused by the browser. The server made a deliberate, narrow grant.

The browser divides cross-origin requests into two kinds. A simple request goes straight out, and the browser checks the response headers afterward to decide whether the calling script may read the body. A request counts as simple when it uses GET, HEAD, or POST, carries only a short list of safe headers, and (for POST) uses a content type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Anything outside that set triggers a preflight.

The preflight check

For requests that could have side effects the browser cannot safely make speculatively (a PUT, a DELETE, a JSON body, a custom header), the browser sends a preflight first: an OPTIONS request that asks the server for permission before the real one goes out.

OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type

The server answers with what it permits:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600

Only if the preflight approves the method and headers does the browser send the real request. Access-Control-Max-Age lets the browser cache that approval so it does not preflight every call.

HeaderDirectionPurpose
OriginRequestThe origin making the call
Access-Control-Request-MethodPreflight requestThe method the real request will use
Access-Control-Request-HeadersPreflight requestThe headers the real request will send
Access-Control-Allow-OriginResponseThe single origin allowed to read the response
Access-Control-Allow-MethodsPreflight responseMethods the server permits
Access-Control-Allow-HeadersPreflight responseRequest headers the server permits
Access-Control-Allow-CredentialsResponseWhether cookies and auth may be included
Access-Control-Expose-HeadersResponseWhich response headers script may read
Access-Control-Max-AgePreflight responseHow long to cache the preflight result

One header on that list surprises people. By default, script on another origin can read only a small set of safe response headers, even after a successful CORS response. If your frontend needs to read a custom header, say a pagination total or a request identifier, the server has to name it in Access-Control-Expose-Headers. This is the same allowlist principle running in the other direction: the browser exposes only what the server explicitly permits.

Understanding this also demystifies the errors. When a CORS request fails, the browser blocks your script from reading the response and reports a CORS error in the console of the page that made the call. The request often reached the server and the server often answered; the browser simply refused to hand the answer back because the headers did not grant permission. So the fix is almost always on the server that owns the resource, adjusting the response headers, rather than in the frontend that received the error.

Credentials change the rules

By default the browser does not send cookies or HTTP auth on cross-origin requests, and it does not expose the response if it did. To include credentials, the frontend opts in (for example fetch(url, { credentials: 'include' })), and the server must return Access-Control-Allow-Credentials: true.

The specification adds a hard constraint here: when credentials are allowed, Access-Control-Allow-Origin may not be the wildcard *. It must name a specific origin. This exists to stop a server from accidentally telling every site on the internet that it may read authenticated responses.

Reflecting Origin is the classic mistake

Echoing back whatever arrives in the Origin request header and pairing it with Access-Control-Allow-Credentials: true tells the browser that any site may read authenticated responses from your API. That combination turns CORS into an account-takeover primitive: a malicious page reads a victim's data straight from your API using the victim's own session. Allow only a fixed list of known origins, compare against it exactly, and never reflect an arbitrary origin alongside credentials.

Common misconfigurations

Most CORS vulnerabilities come from trying to be flexible about which origins to accept. A few patterns recur.

  • Reflecting the Origin header with credentials, covered above, is the headline mistake and the most dangerous.
  • Trusting the null origin. Some setups add null to the allowlist to support local files or sandboxed frames. An attacker can make requests present a null origin from a sandboxed iframe, so Access-Control-Allow-Origin: null with credentials is effectively a wildcard.
  • Sloppy subdomain matching. A check that accepts anything ending in example.com will also accept evil-example.com or example.com.attacker.net. Match origins against a precise list, not a loose substring or a weak regex.
  • Wildcard with credentials. Browsers reject Access-Control-Allow-Origin: * when credentials are requested, so some servers "fix" this by reflecting the origin, which reintroduces the first problem. If you need credentials, you need a specific allowlisted origin, full stop.
  • Treating CORS as access control. A permissive policy does not grant access by itself, and a strict one does not authorize anything. CORS decides which origins may read a browser response. Authentication and authorization on the endpoint are separate and must hold regardless.

How to configure it safely

The safe pattern is deny-by-default with a short, explicit allowlist.

  1. Keep a fixed list of trusted origins in configuration. On each request, check the incoming Origin against that list. If it matches, echo that exact origin back in Access-Control-Allow-Origin. If it does not, send no CORS headers at all.
  2. Add Vary: Origin whenever you set the allow-origin dynamically, so caches do not serve one origin's approval to another.
  3. Never pair credentials with a wildcard or a reflected origin. If a route needs credentials, it needs a specific, allowlisted origin.
  4. Allow the minimum methods and headers. List only the methods and request headers your API actually uses, not a permissive catch-all.
  5. Do not treat CORS as authorization. It controls which origins may read a browser response. It is not a substitute for authentication and access control on the API itself, which must stand on their own.
Where misconfigured CORS shows up

Our Exploit Intelligence dashboard tracks the real-world API and access-control weaknesses attackers chain together, which is useful context when you decide how tightly to scope the origins, methods, and credentials your endpoints permit.

A worked example: tracing one preflighted request

Walk through a single call from a frontend on https://app.example.com that deletes an order through an API on https://api.example.com. The frontend runs fetch with method DELETE, an Authorization header carrying a token, and credentials: 'include'.

The DELETE method is not on the simple list, and the Authorization header is not a safe header, so the browser does not send the delete immediately. It sends a preflight first:

OPTIONS /orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization

Notice that no Authorization header and no cookies ride on the preflight. The preflight is a permission question, so it carries no credentials and no body. The server receives it, checks the incoming Origin against its configured allowlist, finds https://app.example.com on the list, and answers:

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: authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

The browser reads that response and checks three things. The requested method DELETE appears in the allowed methods. The requested header authorization appears in the allowed headers. Credentials are permitted and the allow-origin names a specific origin rather than the wildcard, which the specification requires when credentials are involved. All three pass, so the browser sends the real request, this time with the Authorization header and any cookies attached:

DELETE /orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer ...

The server deletes the order and returns a normal response that again carries Access-Control-Allow-Origin: https://app.example.com and Access-Control-Allow-Credentials: true. Only because those headers are present does the browser hand the response body back to the calling script. Because the server cached the preflight approval for 600 seconds with Access-Control-Max-Age, the next delete within that window skips the OPTIONS round trip entirely.

The preflight is authorization for the browser, not the server

A preflight tells the browser whether the real request is allowed to be sent and read from script. It does not authenticate the user and it does not authorize the action on the server. The server must still check the token on the real DELETE and confirm the user may delete that order. A passing preflight with a valid token still needs an access-control check on the resource itself, because CORS decides which origins may read a browser response, and it says nothing about who is allowed to perform the action.

How CORS evolved

Before CORS existed, the same-origin policy was close to absolute for reading responses, and developers reached for awkward workarounds to share data across origins. The most common was JSONP, which abused the fact that a <script> tag can load from any origin. A page would load a script URL from another origin, and the response would be a function call wrapping the data. It worked, and it was dangerous, because it required the requesting page to execute code returned by the other origin, with no way to constrain what that code did.

CORS, standardized as part of the web platform and built on the origin concept defined in RFC 6454, replaced that pattern with an explicit, header-driven consent model. Instead of tricking the browser into running foreign code, a server declares in response headers which origins may read its responses, and the browser enforces that declaration. The preflight mechanism was added so the browser could ask permission before sending requests that might have side effects, closing the gap where a cross-origin DELETE or a request with a custom header could reach a server that never expected cross-origin callers.

The credentials rule, that Access-Control-Allow-Origin may not be the wildcard when Access-Control-Allow-Credentials is true, was a deliberate guard against the most damaging misconfiguration: a server accidentally telling every origin on the internet that it may read authenticated responses. That single constraint is why the reflected-origin pattern is so tempting and so dangerous, because reflecting the origin is the shortcut developers reach for when the wildcard is refused.

Detection signals

CORS problems show up in two very different places: the browser console when the policy is too strict, and a security review when the policy is too loose. Concrete indicators:

  • A CORS error in the console naming your API. The request usually reached the server and the server usually answered. The browser refused to expose the response because the headers did not grant permission. The fix is almost always on the server that owns the resource, adjusting its response headers.
  • The response echoes back the exact Origin you sent. In a review, send a request with an arbitrary Origin header, for example https://attacker.example, and inspect the response. If Access-Control-Allow-Origin comes back as https://attacker.example, the server is reflecting the origin. Paired with Access-Control-Allow-Credentials: true, that is an account-takeover finding.
  • Access-Control-Allow-Origin: null in a response. A null origin can be produced by a sandboxed iframe an attacker controls, so allowing null with credentials is effectively a wildcard for authenticated reads.
  • Missing Vary: Origin on a dynamically set allow-origin. Without it, a shared cache can store one origin's approval and serve it to another origin, turning a correct per-origin decision into a cross-origin leak.
  • A loose subdomain match. Test origins like https://evil-example.com and https://app.example.com.attacker.net against an API that claims to allow example.com subdomains. If either is accepted, the matching logic uses a substring or weak regex instead of an exact allowlist.
  • Wildcard allow-origin on an authenticated API. Access-Control-Allow-Origin: * on an endpoint that returns user-specific data is a signal the developer either does not send credentials cross-origin or has misunderstood what the wildcard permits.

CORS is one of several browser mechanisms that people conflate. Keeping them distinct clarifies what each does.

MechanismWhat it controlsWhat it does not do
Same-origin policyWhether script may read a cross-origin responseDoes not block the request being sent, or cookies being attached
CORSWhich origins a server consents to let read its responsesIs not authentication or authorization on the endpoint
CSRF defensesWhether a state-changing cross-origin request is honoredDo not control who may read the response
Content Security PolicyWhich sources a page may load resources and scripts fromDoes not govern who may read your API cross-origin

The row that matters most is CSRF. The same-origin policy blocks reading cross-origin responses, and it lets the request be sent with the user's cookies, which is exactly the gap CSRF exploits. CORS does not fix CSRF, because CORS governs reading responses while CSRF is about the request being sent and acted on. They are separate problems with separate defenses, and a permissive CORS policy can widen the impact of a CSRF-adjacent bug by also letting the attacker read the result.

Common misconceptions

"CORS makes my API more secure." CORS decides which origins may read a browser response. A restrictive CORS policy does not authenticate anyone, and a permissive one does not grant access to anything on its own. Authentication and authorization on the endpoint are separate and must hold regardless of the CORS configuration.

"A CORS error means my request failed." The request often succeeded and the server often answered. The browser blocked your script from reading the response because the headers did not grant permission. The server may have already performed the action.

"CORS protects non-browser clients." CORS is enforced by browsers. A command-line client, a server-to-server call, or a script that ignores CORS headers is unaffected. CORS is not a server-side access control, so it protects browser users and nothing else.

"Reflecting the origin is the same as an allowlist." Reflecting whatever origin arrives accepts every origin. It only looks like an allowlist because most requests come from your own frontend. An attacker's page sends its own origin, and the server reflects it, which is the whole vulnerability.

"Adding the wildcard fixes credentialed requests." Browsers reject Access-Control-Allow-Origin: * when credentials are requested. The correct fix is a specific allowlisted origin, not a workaround that reflects the origin.

Frequently asked questions

Why does the browser send an OPTIONS request I did not write? That is the preflight. For requests that are not simple, using a method like PUT or DELETE, a custom header, or a JSON content type, the browser asks the server for permission before sending the real request. It happens automatically and you do not code it. The server answers with the methods, headers, and origin it permits, and only then does the browser send your actual call.

How do I fix a CORS error in my frontend? Usually you do not fix it in the frontend at all. The browser is refusing to hand back a response because the server that owns the resource did not include the right Access-Control-Allow-Origin header. Adjust the server's response headers to name your frontend's origin. If you do not control that server, you may need a same-origin proxy that adds the headers.

Can I just set Access-Control-Allow-Origin: * to make it work? Only for endpoints that return no credentialed, user-specific data. The wildcard tells every origin it may read the response, and browsers refuse to combine it with credentials. For anything authenticated, use a fixed allowlist and echo the specific matching origin.

Is reflecting the Origin header ever safe? Only if you first check it against a strict allowlist and reflect it only on a match, sending no CORS headers otherwise. Reflecting an arbitrary origin, especially alongside Access-Control-Allow-Credentials: true, lets any site read authenticated responses from your API using the victim's session. Compare against a fixed list exactly, and remember to add Vary: Origin.

Does CORS stop cross-site request forgery? No. CORS governs whether script may read a cross-origin response. CSRF is about a state-changing request being sent with the user's cookies and acted on by the server. The same-origin policy deliberately allows the request to be sent, which is why CSRF needs its own defenses like anti-forgery tokens or SameSite cookies. Treat them as separate problems.

Why can my script not read a custom response header even after CORS succeeds? By default the browser exposes only a small set of safe response headers to cross-origin script. To let your frontend read a custom header, such as a pagination total or a request identifier, the server must name it in Access-Control-Expose-Headers. It is the same allowlist principle applied to which response headers script may read.

CORS is a consent mechanism, and the safest consent is specific. Name the origins you trust, refuse the rest, keep credentials off the wildcard path, and remember that the browser is protecting your users even when it is inconveniencing your frontend. Configure it as an allowlist and the errors stop being mysterious.

Sources & further reading

Sharetwitterlinkedin

Related guides