CORS starts with the same-origin policy
The same-origin policy is a fundamental browser security mechanism. It restricts a document or script from reading data that comes from another origin. One of its goals is to stop a malicious website from reading data from a service where the user is already signed in. [3]
CORS, or Cross-Origin Resource Sharing, is an HTTP-based mechanism that lets a server relax that rule and explicitly declare which origins may read a response. The Fetch Standard describes CORS as an opt-in protocol. [1][2]
What an origin actually is
| Page | Target URL | Relationship | Why |
|---|---|---|---|
| https://app.example.com | https://app.example.com/api | Same origin | Same scheme, host and port |
| https://app.example.com | https://api.example.com | Different origin | Different host |
| https://app.example.com | http://app.example.com | Different origin | Different scheme |
| https://app.example.com | https://app.example.com:8443 | Different origin | Different port |
| https://app.example.com | https://app.example.com/v2 | Same origin | Only the path changes |
An origin is defined by scheme, host and port. Two URLs are same-origin only when all three values match. The path does not matter. [3][4]
Therefore `https://app.example.com` and `https://api.example.com` are different origins even though they share the same registrable domain. Likewise `http://app.example.com` and `https://app.example.com` differ by scheme, while ports 443 and 8443 create different origins. [4]
Same-origin and same-site are not equivalent concepts. This matters especially for cookies because the `SameSite` attribute operates on the concept of site, while CORS operates on origin. [13]
What it really means when the browser blocks an API
Saying that the browser blocks the request is often too simplistic. For simple cross-origin requests, the browser may send the request, receive a valid HTTP response and then refuse to expose that response to JavaScript when the CORS check fails. [1][6]
For requests that require preflight, the flow is different. The browser sends `OPTIONS` first. If the preflight response does not satisfy CORS requirements, the actual request is not sent. [2][5][6]
This distinction matters when debugging state-changing operations. A CORS error does not automatically mean the backend received no request.
Simple requests: when the browser skips preflight
| Method | Example headers | Simple request | Effect |
|---|---|---|---|
| GET | Accept | Yes | No preflight |
| POST | Content-Type: text/plain | Yes | No preflight |
| POST | Content-Type: application/json | No | JSON triggers preflight |
| PUT | Content-Type: application/json | No | Method triggers preflight |
| GET | Authorization: Bearer ... | No | Header triggers preflight |
A request is considered simple only when it satisfies the CORS safelist conditions. Allowed methods are `GET`, `HEAD` and `POST`. Manually set headers must be safelisted, and `Content-Type`, when set, can use only `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`. [1]
A typical `POST` with `Content-Type: application/json` is not simple and normally triggers preflight. The same is true for `PUT`, `DELETE` or a custom header such as `X-Request-ID`. [1][11]
Preflight OPTIONS: what the browser checks
A preflight is an automatic `OPTIONS` request. The browser sends `Origin`, `Access-Control-Request-Method` and, when needed, `Access-Control-Request-Headers`. The server answers with the origins, methods and headers that it allows. [2][5]
If an application wants to send `DELETE` with an `Authorization` header, the browser may first ask whether that origin is allowed to use `DELETE` and that header. Only an approving response allows the real request to proceed. [5]
A preflight adds an extra round trip, so its result can be stored in a dedicated CORS preflight cache that is separate from the ordinary HTTP cache. [5][10]
The most important CORS headers
| Header | Direction | Purpose |
|---|---|---|
| Origin | Request | Origin initiating the request |
| Access-Control-Allow-Origin | Response | Origin whose code may receive the response |
| Access-Control-Allow-Methods | Preflight response | Methods allowed by preflight |
| Access-Control-Allow-Headers | Preflight response | Request headers allowed by preflight |
| Access-Control-Allow-Credentials | Response | Permission to expose a credentialed response |
| Access-Control-Expose-Headers | Response | Additional response headers exposed to JavaScript |
| Access-Control-Max-Age | Preflight response | How long the preflight result can be cached |
| Vary: Origin | Response | Tells caches that the response varies by Origin |
`Origin` is a request header that identifies the initiating origin. `Access-Control-Allow-Origin` is a server response header that tells the browser whether the response may be shared with code from that origin. [1][2][8]
`Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` are especially important for preflight. `Access-Control-Allow-Credentials` allows a credentialed response to be exposed, while `Access-Control-Expose-Headers` can expose additional response headers to JavaScript beyond the safelist. [1][2]
When a server dynamically returns different `Access-Control-Allow-Origin` values depending on the request, MDN recommends `Vary: Origin` so caches know that the response varies by origin. [1]
`Access-Control-Allow-Origin`: wildcard or explicit origin
`Access-Control-Allow-Origin: *` allows a response to be shared with any origin for requests without credentials. That can be correct for a genuinely public API. [2][8]
For private or sensitive endpoints, the allowed set should be as small as possible. MDN and OWASP recommend specific origins instead of an unconditional wildcard when broad access is not required. [14][17]
If multiple origins are allowed, the server should compare the incoming `Origin` against an allowlist and return the specific accepted value. It should not blindly reflect any `Origin` supplied by the client. [8][16]
Cookies and credentials: a common source of failures
Fetch can use `credentials: "include"` to request credentials on cross-origin requests. That client option is not enough. The server must return `Access-Control-Allow-Credentials: true` and a specific `Access-Control-Allow-Origin`; the wildcard is not allowed in this case. [2][6][9]
Cookies are also governed by their own rules. `SameSite` may prevent a cookie from being sent on a cross-site request, and `SameSite=None` requires `Secure`. [13]
CORS and `SameSite` solve different problems. CORS can be correct while the cookie is absent because of `SameSite`, or the cookie can be sent while JavaScript is still denied access to the response by CORS.
Why `mode: "no-cors"` usually does not fix anything
`fetch(..., { mode: "no-cors" })` is not a way to bypass CORS when an application needs to read the API response. The request has additional method and header restrictions, and the response is opaque. JavaScript cannot read its body or headers, and the status exposed by the API is `0`. [6][7][11]
`no-cors` has specialized uses, including some Service Worker scenarios, but it is usually the wrong answer for a typical JSON API call. [6]
If you do not control the external API and it does not expose the resource through CORS, a backend or proxy that you control can make the server-to-server request instead. [11][12]
Why curl, Postman or a backend can work while the browser fails
The same-origin policy and CORS enforcement are browser security mechanisms. An HTTP client outside that browser model can send a request without being subject to the browser's CORS check. This is why an endpoint can work in curl or an API tool while JavaScript from a page is blocked. [2][3][16]
This also shows why CORS is not API authentication. A non-browser client can choose its own `Origin` header, so OWASP warns against using that header as proof of client identity. [16]
The API still needs normal authorization, tokens, sessions, permission checks and server-side validation.
CORS does not replace CSRF protection or authorization
The same-origin policy primarily restricts cross-origin reads. It does not mean every state-changing request from another site is impossible. Forms and some simple requests can be sent cross-origin, so cookie-based applications still need CSRF defenses. [3][15]
OWASP explicitly recommends not relying on CORS or `Origin` alone for access control over sensitive resources. Sensitive endpoints need authentication and authorization independently of CORS. [16]
CORS answers whether a browser may expose a response to code from a given origin. It does not answer whether a user or client is authorized to perform the operation.
Common errors and how to debug them
A missing `Access-Control-Allow-Origin` header is one of the most common errors. Other frequent causes include a disallowed method, a missing token in `Access-Control-Allow-Headers`, use of `*` with credentials, a broken OPTIONS response or redirects in the CORS flow. [11][12]
JavaScript intentionally receives limited detail about the reason for a CORS failure. The browser console and Network panel provide the more useful diagnostics. [1][11]
`CORS request did not succeed` does not necessarily indicate a CORS policy error. MDN notes that DNS failure, timeout, connection refusal, TLS problems, mixed content or extensions can produce that message. [11]
- Confirm the exact frontend origin: scheme, host and port.
- Inspect the `OPTIONS` request when one exists.
- Check the preflight status and all relevant `Access-Control-Allow-*` headers.
- Verify that the actual response also contains the required CORS headers.
- For cookies, inspect `credentials`, `Access-Control-Allow-Credentials`, `SameSite` and `Secure`.
- Check redirects, TLS, mixed content and network failures.
- Compare the browser request with a working curl or API-tool request.
Dangerous CORS configurations
Blindly reflecting the incoming `Origin` can be dangerous when credentialed requests return sensitive data. An attacker can host a page on a controlled origin and attempt to read responses in the context of a signed-in user if that origin is accepted. [16]
Overly broad regular expressions and trust in every subdomain can also be risky if one subdomain can be taken over or controlled by another party. OWASP recommends exact allowlist matching for trusted origins. [16][17]
`Access-Control-Allow-Origin: *` is not automatically a vulnerability for completely public data without credentials. The problem is a policy that exposes more data or more origins than intended. [8][17]
Performance: preflight cost and `Access-Control-Max-Age`
Preflight adds an extra `OPTIONS` request before the real request, so it can increase latency. Its result can be cached with `Access-Control-Max-Age`, and the preflight cache is separate from the normal HTTP cache. [5][10]
MDN states that the default when the header is absent is 5 seconds. Browsers can impose their own upper limits even if the server sends a larger value. Firefox caps it at 86400 seconds, while Chromium from version 76 caps it at 7200 seconds. [10]
Stable preflight policies are worth caching, but not at the cost of making an accidentally broad security policy difficult to revoke quickly.
Practical configuration and deployment checklist
First decide whether the endpoint should be cross-origin at all. If not, do not add CORS headers. If it should, define an explicit allowlist of origins, the required methods and headers, and whether credentials are necessary. [14][17]
For a dynamic allowlist, return only a verified origin and include `Vary: Origin`. For credentials, return a specific origin and `Access-Control-Allow-Credentials: true`. Do not try to fix CORS by adding response headers in frontend code. [1][2]
After deployment, test a simple request, a preflighted request, a cookie scenario, a disallowed origin and real error behavior in DevTools.
- Is cross-origin access actually required?
- Which exact origins must be allowed?
- Are credentials required?
- Which methods and request headers are needed?
- Does `OPTIONS` reach the application and return a correct response?
- Does the actual response contain the required CORS headers?
- Do dynamic-origin responses include `Vary: Origin`?
- Does the endpoint still require normal authentication and authorization?
- Has the policy been tested with both allowed and disallowed origins?

