X

CORS for Browser-Based API Integrations: A Practical Guide

When a browser app calls an API on another origin, the request may reach the server but JavaScript still cannot read the response. That is usually a Cross-Origin Resource Sharing (CORS) issue, not a bug that can be fixed by adding a request option in the frontend.

CORS is a browser-enforced agreement expressed through HTTP response headers. This guide explains origins, preflight requests, credentials, and a practical way to debug an integration you control.

What counts as a different origin?

An origin consists of a scheme, host, and port. For example, https://app.example.com and https://api.example.com are different origins because their hosts differ. http://localhost:5173 and http://localhost:3000 are also different because their ports differ. A URL's path does not change its origin.

Browsers apply the same-origin policy to scripts. For a cross-origin fetch() request, the browser uses CORS to decide whether it can make the response available to the calling script. The API server signals its policy with response headers such as Access-Control-Allow-Origin.

CORS is not authentication or authorization. It does not stop curl, another server, or a malicious client from sending a request. It also does not replace protections against cross-site request forgery (CSRF) for cookie-authenticated actions. Authenticate and authorize requests at the API, and use CSRF defenses where the application's authentication model requires them.

When does the browser send a preflight?

Some cross-origin requests can be sent without an OPTIONS preflight. The current Fetch Standard calls their method and headers CORS-safelisted; older guides often call these “simple requests.” For example, a GET with ordinary safelisted headers usually does not need a preflight.

The browser sends a preflight when a request uses a method, header, or content type that is not safelisted. A JSON POST with Content-Type: application/json, a PUT, or a custom X-Client-Version header will commonly trigger one. The browser first asks whether the origin, method, and headers are allowed. If the check succeeds, it sends the actual request.

For example, this request needs the API to allow POST and the Content-Type header:

const response = await fetch("https://api.example.com/v1/items", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Notebook" }),
});

if (!response.ok) {
  throw new Error(`Request failed: ${response.status}`);
}

The browser sends an OPTIONS request first, similar to:

OPTIONS /v1/items HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

If that origin and request are allowed, the API can answer with:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Vary: Origin

The actual response also needs the appropriate Access-Control-Allow-Origin header. If the server chooses that header dynamically from an allowlist, include Vary: Origin on the relevant responses so shared caches do not reuse one origin's response for another. Access-Control-Max-Age lets browsers cache preflight results, subject to each browser's own maximum.

The preflight is not the API operation itself. It does not carry the user's cookies, so an authentication layer that rejects every OPTIONS request can prevent the browser from reaching the actual endpoint. Handle preflight according to the API's CORS policy; continue to authenticate and authorize the actual request normally.

Configure the API, not the browser

The server that responds to the browser request must send the CORS headers. A reliable configuration should:

  1. Allow only the origins you intend to support. Match the exact scheme, host, and port, such as https://app.example.com. Do not reflect every incoming Origin value or use null as a shortcut.
  2. Allow only the methods and request headers the endpoint needs. If the browser requests Authorization, the preflight response must allow that header. Do not copy the Access-Control-Request-Headers value into a response without checking it against policy.
  3. Handle OPTIONS without an authentication redirect. Preflight requests do not contain the normal cross-origin credentials. Return the required CORS headers for allowed requests, then enforce authentication and authorization on the actual API request.
  4. Send CORS headers on error responses too. If a response such as a 401 or 500 lacks the expected headers, the browser may hide its status and body from JavaScript, making an API error look like a CORS failure.
  5. Account for caches. If the allowed origin varies by request, return Vary: Origin. Check that a CDN or reverse proxy preserves the relevant headers and does not cache a response under the wrong origin.

For a public, read-only endpoint that does not use credentials, Access-Control-Allow-Origin: * can be appropriate. It is not a good default for private or user-specific data. When the server supports a set of trusted origins, compare the request's Origin against that explicit allowlist and return the matching origin only when it is allowed.

If browser code needs to read a non-safelisted response header, such as a custom request ID, the server must also list it in Access-Control-Expose-Headers. Allowing a request header and exposing a response header are separate decisions.

Cookies and credentialed requests

Cross-origin fetch() does not include cookies by default. To request cookies, a frontend can opt in:

const response = await fetch("https://api.example.com/v1/profile", {
  credentials: "include",
});

The API must then return both an explicit allowed origin and:

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

The wildcard origin (*) cannot be used for a credentialed CORS response. The preflight response must also allow credentials before the browser sends the actual credentialed request. Use this mode only when the application needs it; cookies remain subject to SameSite, Secure, and browser third-party-cookie policies. CORS headers do not override those cookie rules.

Common fixes that do not solve CORS

  • Adding mode: "cors" to fetch(): cors is already the default mode for fetch(). The API still has to return the required headers.
  • Setting mode: "no-cors": This produces an opaque response that JavaScript cannot inspect. It is not a way to read a protected API response.
  • Adding Access-Control-Allow-Origin to the request: This is a response header. The API, not browser JavaScript, decides which origins it allows.
  • Using * with cookies: Browsers reject wildcard origins for credentialed CORS requests. Return a specific allowlisted origin instead.
  • Treating a successful command-line request as proof: Tools such as curl do not enforce the browser's same-origin policy. A response that works in curl can still fail when fetched by a web page.

During local development, a development-server proxy can forward a same-origin request to an API you control, avoiding a browser cross-origin call in that setup. Production needs its own correct CORS policy or a deliberate same-origin proxy; a development proxy does not change the API's production configuration.

Debug a browser CORS error

JavaScript generally receives a network-style failure rather than the detailed reason for a CORS rejection. Use the browser developer tools:

  1. Read the console's CORS message, then find the request in the Network panel.
  2. If there is an OPTIONS request, inspect its status and the Origin, Access-Control-Request-Method, and Access-Control-Request-Headers values.
  3. Compare them with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers in the preflight response. Check spelling, ports, schemes, and redirects.
  4. If preflight succeeds, inspect the actual response too. It must include the right CORS headers, including on error responses. For cookie requests, check the credential headers and the browser's cookie policy.
  5. Check every layer that can change the response: application middleware, a reverse proxy, a CDN, and redirects.

Keep the browser check separate from API-level checks. An API client can verify that the endpoint returns the expected status and body, but only a browser request from the real frontend origin confirms that the CORS policy permits the page to read it. Our guide to API testing and documentation workflows covers repeatable API checks; for browser authentication patterns, see OAuth 2.0 with PKCE for browser-based applications.

A practical checklist

Before deploying a browser-to-API integration, confirm that:

  • The frontend and API origins are identified exactly, including development ports.
  • The API allowlist contains only trusted frontend origins.
  • Preflight supports the requested methods and headers.
  • Credentialed requests use an explicit origin and Access-Control-Allow-Credentials: true only when needed.
  • Actual responses and errors include the expected CORS headers.
  • Dynamic origin responses use Vary: Origin where caches are involved.
  • API authentication, authorization, and CSRF protections are enforced independently of CORS.
  • The request succeeds in a browser using the same origin and credentials settings as the deployed frontend.

Sources

Categories: Development
Related Post