A browser application often needs to call an API on another origin. The first time this fails, Cross-Origin Resource Sharing (CORS) can look like a networking obstacle: the request reached the server, the server returned data, yet JavaScript cannot read the response. That view leads to a dangerous fix—making the CORS policy broad until the error disappears.

CORS is better understood as a browser-enforced read permission. Your server uses HTTP response headers to tell a browser which other origins may expose a response to their JavaScript. If a sensitive API grants that permission too broadly, code running on an unintended website may be able to read data in a user’s browser context. If the policy is too narrow, legitimate frontends stop working.

This article develops a practical mental model for CORS, shows how to choose an origin policy, and explains why credentials, preflight requests, caching, and server-side authorization must be considered separately.

Start with the browser’s same-origin boundary

An origin is identified by a URL’s scheme, host, and port. These are different origins:

https://app.example.com
https://admin.example.com
https://app.example.com:8443
http://app.example.com

Browsers apply the same-origin policy to many interactions between origins. For fetch() and similar APIs, JavaScript generally cannot read an ordinary cross-origin response unless the target server opts into that access through CORS.

Suppose a page loaded from:

https://dashboard.example

calls:

https://api.example/account

Those URLs have different hosts, so they are different origins. If the API intends the dashboard to read its response, it can return:

Access-Control-Allow-Origin: https://dashboard.example

That header tells a conforming browser that script from that origin may receive the response through CORS.

The important security question is therefore not “Which domains can reach this server?” The request may reach the server regardless. The useful question is:

Which browser origins should be allowed to make this response available to their JavaScript?

That distinction prevents several common design mistakes.

CORS is not server-side authorization

CORS does not decide whether the requester is allowed to perform an application action. Your API still needs authentication and authorization.

Consider an endpoint that returns invoice 42. A sound server-side flow might be:

request
  -> authenticate user
  -> authorize user for invoice 42
  -> build response
  -> apply CORS policy for the requesting browser origin

Authorization answers whether the authenticated principal may access the invoice. CORS answers whether browser code from another origin may read the resulting response.

These controls solve different problems. A command-line HTTP client, backend service, or other non-browser client is not made harmless by your CORS policy. Such clients can send requests without relying on browser CORS enforcement. Conversely, correct object authorization does not mean every website should be allowed to read a user’s authorized responses through that user’s browser.

Treat CORS as an additional browser trust boundary, not as a replacement for API access control.

Make the public case deliberately public

Some resources are intentionally readable by anyone. A public status endpoint, public catalogue, or other response containing no user-specific or confidential information may reasonably allow every browser origin to read it:

Access-Control-Allow-Origin: *

The wildcard is useful when the intended policy really is “any origin may read this response” and the request does not use credentials in the CORS sense.

Do not use the wildcard merely because maintaining an allowlist seems inconvenient. The policy should follow the sensitivity of the resource. If a response becomes personalized later, a previously harmless public CORS policy can become part of a data-exposure path.

A practical review question is: Would it be acceptable for JavaScript on an arbitrary website to receive this response? If the answer is no, * does not describe the intended trust boundary.

Credentialed requests need a narrower decision

A cross-origin request can be made with credentials, such as cookies, when the browser and application are configured to do so. This changes the risk because the request may carry a user’s authenticated state.

Imagine that https://account.example accepts a session cookie and returns private profile data. The legitimate frontend is https://portal.example. If the API intends that frontend to make credentialed cross-origin requests, a response can include:

Access-Control-Allow-Origin: https://portal.example
Access-Control-Allow-Credentials: true

The browser also needs the client request to use the appropriate credentials mode. For example, fetch() can request credential inclusion with credentials: "include".

For credentialed CORS, Access-Control-Allow-Origin: * cannot be used as a wildcard permission. The server must identify an allowed origin explicitly. This is a useful constraint: authenticated browser access should be tied to origins you deliberately trust.

The threat model is an attacker who can cause a user to visit an attacker-controlled origin while that user has authenticated state for your service. A narrow CORS policy reduces the risk that the attacker’s JavaScript can read credentialed API responses. It does not stop every cross-site request, protect against script already running in an allowed origin, or replace CSRF defenses where those are needed.

Validate an origin before reflecting it

Applications that support several frontends often inspect the request’s Origin header and dynamically return an Access-Control-Allow-Origin value. That can be correct, but only after an explicit policy decision.

A dangerous pattern is conceptually:

receive Origin
return Access-Control-Allow-Origin: <the same value>

with no validation. This converts “allow these known frontends” into “allow whichever origin asks.”

Instead, parse the origin and compare it against a configured set of origins that are actually trusted for the resource:

allowed origins:
  https://app.example
  https://admin.example

request Origin: https://app.example
result: allow

request Origin: https://other.example
result: no CORS permission

Compare origins as origins, including scheme, host, and port where relevant. Avoid substring rules such as “contains example.com” or “ends with example.com” unless you have carefully defined and parsed the hostname boundary. A hostname such as notexample.com demonstrates why textual suffix logic can express a different policy from the one you intended.

Be equally careful with broad subdomain trust. Allowing every subdomain means a compromise or unsafe delegated subdomain may gain browser read access too. If only two applications need access, naming those two origins is easier to reason about.

Avoid granting access to the serialized null origin as a shortcut. Sandboxed documents and some non-hierarchical URL schemes can have a null origin, so it is not a useful stand-in for one uniquely trusted application.

Understand what preflight actually proves

Some cross-origin requests cause the browser to send an OPTIONS preflight request before the actual request. The browser uses it to ask whether the server permits the intended method and request headers for that cross-origin operation.

A simplified exchange looks like this:

OPTIONS /settings HTTP/1.1
Origin: https://portal.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

A server that permits this operation might respond with:

Access-Control-Allow-Origin: https://portal.example
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Content-Type

If credentials are intended for the actual CORS request, the response also needs the appropriate credentials permission. The CORS preflight itself does not carry those credentials under the Fetch standard.

Preflight is not an authentication handshake and should not be treated as proof that the later caller is authorized. Its purpose is to let the browser evaluate cross-origin protocol permission before making certain requests. The actual endpoint still needs normal authentication, authorization, input validation, and other controls.

Also avoid designing security around the assumption that every state-changing cross-origin request is preflighted. Some requests use CORS-safelisted methods and headers and can be sent without a preflight. Security-sensitive endpoints must be safe even when no preflight occurs.

Keep CORS permission as narrow as the API contract

CORS configuration has several dimensions. The origin is the most important trust decision, but method and header permissions should also match the intended client contract.

Suppose a browser frontend only needs to read reports with GET. Advertising unrelated methods through the preflight policy makes the configuration harder to audit. Likewise, allowing arbitrary request headers when the frontend needs only a small known set creates unnecessary policy surface.

A useful configuration process is:

  1. identify the browser origins that genuinely need cross-origin access;
  2. identify which API routes those origins need;
  3. decide whether the responses are public or credentialed;
  4. permit only the methods and request headers required by that browser client;
  5. keep server-side authentication and authorization independent of CORS.

This is not about minimizing headers for appearance. It makes the declared browser permission correspond to a real application dependency, which makes later changes easier to review.

Account for caches when the allowed origin varies

A server may dynamically return a different Access-Control-Allow-Origin value depending on the request’s Origin. When the same URL can therefore produce origin-dependent response headers, caches need to know that Origin affects the representation.

For HTTP responses that can be cached, include:

Vary: Origin

when you dynamically select a specific allowed origin. This tells compliant caches that responses for different Origin request-header values are not interchangeable solely because the URL matches.

This detail matters because a CORS decision made correctly at the origin server can be undermined operationally if an intermediary reuses response metadata under the wrong request context. It is the same general defensive principle used elsewhere in caching: if a response changes according to security-relevant request state, the cache identity must preserve that distinction or the response should not be shared.

Test the security property, not only the happy path

A CORS configuration is easy to test only from the legitimate frontend and declare complete. That verifies functionality, not the boundary.

For a sensitive endpoint, test at least these cases in an environment that represents production behavior:

  • an explicitly allowed origin receives the expected CORS response;
  • an unlisted origin does not receive browser read permission;
  • a lookalike hostname is rejected;
  • the same host with an unapproved scheme or port is rejected when those differences matter to the policy;
  • credentialed access works only for origins intended to receive it;
  • preflight permits only the methods and headers the frontend requires;
  • dynamically varied origin responses include correct cache variation.

Where possible, test with a real browser or browser automation in addition to unit tests. CORS is enforced by browsers, so an end-to-end test can catch differences between your mental model and actual browser behavior.

Server-side tests should separately verify authentication and authorization. A successful CORS test does not demonstrate that the underlying API access control is correct.

Know what CORS does not protect

A narrow CORS policy reduces one specific class of browser exposure: unintended origins reading cross-origin responses through CORS-enabled browser APIs. Several important risks remain.

If an attacker can execute JavaScript in an origin you already allow—for example because that frontend has a cross-site scripting vulnerability—the script operates from a trusted origin and may pass the CORS check. CORS therefore does not replace output encoding, Content Security Policy, or other defenses against script injection.

CORS also does not generally stop a browser from sending every kind of cross-site request. Cross-Site Request Forgery (CSRF) is concerned with unwanted actions made using a user’s authority; CORS is primarily concerned with whether JavaScript can read cross-origin responses. The two controls interact, but they are not interchangeable.

Finally, CORS is not a network access control. If an API must be unreachable from untrusted networks, enforce that requirement at the appropriate network and service boundaries rather than expecting browser headers to provide it.

Choose the simplest policy that matches the trust boundary

For a truly public, non-credentialed resource, allowing all origins can be simple and appropriate. For a private browser API used by a small set of frontends, an explicit origin allowlist is usually easier to understand and review. If many independently operated origins need access, treat that as a real trust-model requirement rather than hiding it behind permissive reflection.

The practical mental model is straightforward: CORS tells browsers which origins may hand a response to their JavaScript. Decide that permission from the sensitivity of the response and the frontend relationships you actually intend. Keep credentialed access narrow, validate origins before reflecting them, account for preflight and caching, and continue to enforce authentication and authorization on the server.

When CORS is treated as a security boundary instead of an error message to suppress, its configuration becomes much easier to reason about.