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.com | Same origin? | Why |
|---|---|---|
https://app.example.com/data | Yes | Path does not affect origin |
http://app.example.com | No | Different scheme |
https://api.example.com | No | Different host |
https://app.example.com:8443 | No | Different port |
https://example.com | No | Different 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.
| Header | Direction | Purpose |
|---|---|---|
| Origin | Request | The origin making the call |
| Access-Control-Request-Method | Preflight request | The method the real request will use |
| Access-Control-Request-Headers | Preflight request | The headers the real request will send |
| Access-Control-Allow-Origin | Response | The single origin allowed to read the response |
| Access-Control-Allow-Methods | Preflight response | Methods the server permits |
| Access-Control-Allow-Headers | Preflight response | Request headers the server permits |
| Access-Control-Allow-Credentials | Response | Whether cookies and auth may be included |
| Access-Control-Expose-Headers | Response | Which response headers script may read |
| Access-Control-Max-Age | Preflight response | How 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.
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
nullorigin. Some setups addnullto the allowlist to support local files or sandboxed frames. An attacker can make requests present anullorigin from a sandboxed iframe, soAccess-Control-Allow-Origin: nullwith credentials is effectively a wildcard. - Sloppy subdomain matching. A check that accepts anything ending in
example.comwill also acceptevil-example.comorexample.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.
- Keep a fixed list of trusted origins in configuration. On each request, check the incoming
Originagainst that list. If it matches, echo that exact origin back inAccess-Control-Allow-Origin. If it does not, send no CORS headers at all. - Add
Vary: Originwhenever you set the allow-origin dynamically, so caches do not serve one origin's approval to another. - Never pair credentials with a wildcard or a reflected origin. If a route needs credentials, it needs a specific, allowlisted origin.
- Allow the minimum methods and headers. List only the methods and request headers your API actually uses, not a permissive catch-all.
- 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.
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.
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
Originyou sent. In a review, send a request with an arbitraryOriginheader, for examplehttps://attacker.example, and inspect the response. IfAccess-Control-Allow-Origincomes back ashttps://attacker.example, the server is reflecting the origin. Paired withAccess-Control-Allow-Credentials: true, that is an account-takeover finding. Access-Control-Allow-Origin: nullin a response. Anullorigin can be produced by a sandboxed iframe an attacker controls, so allowingnullwith credentials is effectively a wildcard for authenticated reads.- Missing
Vary: Originon 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.comandhttps://app.example.com.attacker.netagainst an API that claims to allowexample.comsubdomains. 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 compared to related boundaries
CORS is one of several browser mechanisms that people conflate. Keeping them distinct clarifies what each does.
| Mechanism | What it controls | What it does not do |
|---|---|---|
| Same-origin policy | Whether script may read a cross-origin response | Does not block the request being sent, or cookies being attached |
| CORS | Which origins a server consents to let read its responses | Is not authentication or authorization on the endpoint |
| CSRF defenses | Whether a state-changing cross-origin request is honored | Do not control who may read the response |
| Content Security Policy | Which sources a page may load resources and scripts from | Does 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.
Related guides
Sources & further reading
Related guides
- CORS Misconfiguration: When Permissive Origins Leak Data
How CORS misconfigurations expose data: reflected origins with credentials, null and wildcard traps, and how to build a safe cross-origin policy.
- GraphQL Attacks: Introspection, Nested Queries & Batching
How GraphQL gets attacked: exposed introspection, deeply nested query denial of service, batching abuse, and defending with depth and cost limits.
- JWT Attacks: alg=none, Algorithm Confusion & Weak Secrets
How JSON Web Token attacks work: the alg=none trick, RS256-to-HS256 algorithm confusion, brute-forcing weak secrets, and how to defend.