Back to Knowledge Hub
Platform

CORS Browser Boundaries: Headers, Preflights, Credentials, and Cache

A practical guide to diagnosing cross-origin fetches without confusing CORS with network reachability, authorization, CSP, or Permissions Policy.

BotBrowser Team

Documentation

Want the structured docs for Platform?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

A browser separates reachable responses, CORS sharing, credentials, and application handling

Cross-Origin Resource Sharing (CORS) is a browser response-sharing protocol. It answers a narrow question: after a page at one origin sends a request to another origin, may browser JavaScript read the response? CORS does not make a route reachable, authorize a user on the server, or make an API trustworthy. A reliable diagnosis records those boundaries separately. This distinction matters in production. A route can be healthy while JavaScript receives an opaque response or a CORS error. A preflight can be rejected before the real request. Credentials can make an apparently correct wildcard policy invalid. A shared cache can replay the wrong origin's response when `Vary: Origin` is missing. Content Security Policy (CSP) can stop a connection before CORS is evaluated, while Permissions Policy controls whether an embedded document may use a feature. These are related browser controls, not interchangeable fixes. ## Origins and evidence An origin is the tuple of scheme, host, and port. the local fixture assigns different localhost ports to its page and API origins at runtime. A different port is also cross-origin. Same-origin policy is the browser's default boundary; CORS is an explicit server response mechanism that lets a browser expose selected cross-origin responses to a requesting origin. The server sees the request and can authenticate it, but the browser decides whether the response becomes readable to the initiating script. DevTools often shows a request with a 200 status and still reports a CORS failure. That is not contradictory: the network exchange completed, while the Fetch layer withheld the response from JavaScript. Record the request URL, request origin, HTTP status, response headers, browser console message, and application state as separate observations. The [Fetch CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol) defines the protocol details. [MDN's CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) provides practical header examples and browser-facing behavior. Neither source says that CORS is server authorization. Your API still needs authentication, authorization, input validation, rate controls, and an application policy for the data it returns. Simple requests and response headers Some cross-origin requests can be sent without a preflight. The browser still applies CORS when exposing the response. A request is commonly considered simple when its method is `GET`, `HEAD`, or `POST`, its author-controlled headers are safelisted, and its `Content-Type` is one of the safelisted values. `application/json` is not a safelisted request content type, so a JSON POST commonly needs a preflight. For a readable response, the server normally returns: ```http Access-Control-Allow-Origin: ``` The value must match the requesting origin or be `*` when the request does not use credentials. The browser compares the response header with the origin it attached to the request; a page cannot choose an arbitrary origin by setting the `Origin` header from JavaScript. A server may allow several known origins, but it must validate that list on the server and emit the selected value. Do not reflect any incoming origin without an allow-list and an authorization design. JavaScript can read only response headers that are CORS-safelisted or named by `Access-Control-Expose-Headers`. A server can return a response body successfully while a custom header remains invisible to the page. Conversely, a readable body does not prove that the operation was authorized or committed by the application. The `mode` option changes Fetch behavior, not server policy. `mode: 'cors'` requests a CORS-readable response. `mode: 'no-cors'` may produce an opaque response for certain requests; an opaque response is not an escape from CORS and its body and most headers are intentionally unreadable. `mode: 'same-origin'` rejects a cross-origin request. Treat these as explicit test inputs and report the resulting browser visibility. ## Preflight is a separate exchange When a request is not simple, the browser first sends an `OPTIONS` preflight. It includes the intended origin, method, and request headers: ```http OPTIONS /reports HTTP/1.1 Origin: Access-Control-Request-Method: POST Access-Control-Request-Headers: authorization, content-type ``` The server must answer with compatible policy headers, for example: ```http HTTP/1.1 204 No Content Access-Control-Allow-Origin: Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: authorization, content-type Access-Control-Max-Age: 300 Vary: Origin ``` The browser validates the preflight response before sending the actual request. A missing method or header permission is a browser policy failure, even if the endpoint would have returned a useful response. A redirect, authentication requirement, proxy rule, or server error during `OPTIONS` can prevent the actual request; investigate the preflight network record before changing application code. Preflight is not a permission request to the end user. It is a protocol check performed by the browser. The response does not grant an account role, and `Access-Control-Allow-Methods` does not authorize a method for a user. Keep server authorization checks on the actual endpoint. Preflight caching can hide a newly deployed header for a period of time. `Access-Control-Max-Age` controls how long a successful preflight may be reused, within browser limits. During diagnosis, use a fresh context or wait for the documented cache window rather than assuming a stale result proves the current configuration. ## Credentials require an explicit origin Cookies, HTTP authentication, and some client certificate behavior are credentials. A page must opt in with `fetch(url, { credentials: 'include' })` for cross-origin cookies to be considered. The server must then return both a matching `Access-Control-Allow-Origin` value and: ```http Access-Control-Allow-Credentials: true ``` `Access-Control-Allow-Origin: *` cannot be combined with a credentialed readable response. Browsers reject that combination because a wildcard would not identify the requesting site. A credentialed request also depends on cookie attributes such as `SameSite`, `Secure`, and domain/path scope. A CORS header cannot make a cookie eligible, and CORS cannot replace CSRF defenses for state-changing actions. The browser may send a request while withholding its response from script. That makes server logs and page behavior look different. Test a credentialed flow with a synthetic account and record whether the cookie was attached, whether the server authenticated it, whether the response headers permit sharing, and whether the application completed its own action. Never use a browser fixture to collect real credentials or infer private account state. Cache correctness and `Vary: Origin` When the server selects an allow-origin value dynamically, the response varies by request origin. Send: ```http Vary: Origin ``` This tells shared caches that a response for one origin must not be reused for another origin. `Vary` does not create CORS permission; it preserves the server's selection across cache layers. Also consider cache keys, CDN behavior, authenticated responses, and whether a response should be cacheable at all. A browser cache, preflight cache, service worker, reverse proxy, and CDN can each affect what you observe. Cache partitioning differs by browser and context, so avoid treating one cache hit as universal behavior. For a deterministic test, use a fresh browser context, a unique query only when the application permits it, and response headers captured at the endpoint. Do not mutate production cache policy merely to make a test pass. A test that observes a cached response proves that one cache path was used, not that all users receive safe isolation. ## CORS is not CSP or Permissions Policy These controls operate at different stages: | Control | Main question | Evidence | Does not prove | | --- | --- | --- | --- | | Network and DNS | Can the browser reach the URL? | DNS, connection, request timing, network error | That a response is readable or authorized | | CORS | May page JavaScript read this cross-origin response? | `Access-Control-Allow-*`, Fetch result, console | Server authorization or business completion | | CSP | Which resource destinations and execution forms may this document use? | `Content-Security-Policy`, violation reports, blocked load | CORS permission or API authorization | | Permissions Policy | May a document or iframe use a delegated browser feature? | Response policy, iframe `allow`, feature result | User permission, device availability, or CORS | [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) uses directives such as `connect-src` to constrain destinations for Fetch and XHR. If CSP blocks the destination, there may be no meaningful CORS response to inspect. Fix the owning policy only when the policy is the intended boundary; do not weaken CSP to hide a CORS diagnosis. [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) controls browser features such as camera or geolocation in documents and frames. Its `allow` attribute and response policy do not grant a server response to JavaScript. A frame can have a permitted feature and still fail CORS when it fetches an API; it can also have valid CORS and lack permission to use a device feature. ## A bounded control and candidate fixture The most useful reproduction keeps reachability constant and changes one declared policy. The conceptual example has an API origin and a page origin, but the self-contained fixture below creates both on runtime localhost HTTP ports (`apiOrigin` and `pageOrigin`). The route returns the same synthetic body for both pages. The control response includes the exact runtime page origin; the candidate deliberately omits it. Do not point this fixture at a third-party API. The following Playwright-style example uses an explicit action, an eight-second navigation timeout, and a five-second status wait. It classifies a network failure separately from a CORS result. The endpoint and pages are owned test fixtures, and cleanup closes the context even when an assertion fails: ```js import { chromium } from 'playwright'; import http from 'node:http'; import assert from 'node:assert/strict';

let pageOrigin = ''; let apiOrigin = ''; const observations = []; const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const close = server => new Promise(resolve => server.close(resolve)); const api = http.createServer((request, response) => { const name = request.url.slice(1); if (name === 'control') response.setHeader('Access-Control-Allow-Origin', pageOrigin); observations.push({ name, status: 200, allowOrigin: response.getHeader('Access-Control-Allow-Origin') || null }); response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"fixture":true}'); }); const pageServer = http.createServer((request, response) => { const name = request.url.slice(1); response.end(<button>Run CORS check</button><output data-testid="cors-status"></output><script> document.querySelector('button').onclick = async () => { try { await fetch('${apiOrigin}/${name}'); document.querySelector('output').textContent = 'CORS_ALLOWED'; } catch { document.querySelector('output').textContent = 'FETCH_UNCLASSIFIED'; } };</script>); }); await listen(api); await listen(pageServer); const apiPort = api.address().port; const pagePort = pageServer.address().port; pageOrigin = http://127.0.0.1:${pagePort}; apiOrigin = http://127.0.0.1:${apiPort}; const browser = await chromium.launch(); const context = await browser.newContext(); context.setDefaultNavigationTimeout(8_000); async function runCase(name) { const page = await context.newPage(); try { await page.goto(${pageOrigin}/${name}); await page.getByRole('button', { name: 'Run CORS check' }).click(); await page.locator('[data-testid="cors-status"]').waitFor({ state: 'visible', timeout: 5_000 }); return await page.locator('[data-testid="cors-status"]').textContent(); } catch { return 'UNKNOWN'; } finally { await page.close(); } } try { const control = await runCase('control'); const candidate = await runCase('candidate'); assert.equal(control, 'CORS_ALLOWED'); assert.equal(candidate, 'FETCH_UNCLASSIFIED'); assert.deepEqual(observations.map(({ name, allowOrigin }) => ({ name, allowOrigin })), [ { name: 'control', allowOrigin: pageOrigin }, { name: 'candidate', allowOrigin: null } ]); } finally { await context.close(); await browser.close(); await close(pageServer); await close(api); }


The control must report `CORS_ALLOWED`; candidate reports `FETCH_UNCLASSIFIED` after Fetch rejects. The code does not treat that rejection as a CORS conclusion. It asserts the server's real observations too: both paths reached the owned API, both returned 200 and the same body, and only candidate omitted the exact page origin in `Access-Control-Allow-Origin`. Together, those observations establish the fixture's `CORS_BLOCKED` result. Navigation, status, server-start, or assertion failures remain `UNKNOWN` and must not be relabeled as CORS or network failures. The eight-second navigation limit, five-second visible-status limit, and `finally` cleanup keep the fixture bounded.

The fixture keeps navigation, status, server evidence, and cleanup boundaries explicit.
## Decision table for incident triage
| Observation                             | First owner       | Next check                                                | Safe conclusion                                              |
| --------------------------------------- | ----------------- | --------------------------------------------------------- | ------------------------------------------------------------ |
| DNS or connection failure               | Network/platform  | Resolve, TLS, proxy, route, and server health             | The page did not reach a CORS response                       |
| Preflight is absent                     | Client/request    | Method, content type, custom headers, service worker      | Request may be simple or intercepted; inspect actual traffic |
| Preflight returns 4xx/5xx               | API/platform      | `OPTIONS` routing, auth middleware, allowed method/header | Preflight exchange failed; actual request may not have run   |
| 200 response, script cannot read it     | API/browser       | Allow-Origin, credential mode, exposed headers, console   | CORS sharing failed or response was opaque                   |
| Cookie missing                          | API/auth/browser  | credentials mode, SameSite, Secure, scope                 | CORS alone cannot make a cookie eligible                     |
| Works for one origin, fails for another | API/cache         | allow-list and `Vary: Origin` at every cache              | Origin-specific policy or cache behavior differs             |
| CSP violation                           | Web security      | `connect-src` and policy reports                          | CSP owns the block; do not label it CORS                     |
| Feature denied in an iframe             | Embedder/platform | Permissions Policy and `allow` delegation                 | Feature policy/permission is separate from API sharing       |
What BotBrowser can and cannot establish
BotBrowser controlled browser contexts can run authorized, owned control and candidate CORS fixtures, isolate synthetic cookies and storage, and compare visible Fetch outcomes on a declared browser build. That is useful for regression evidence: the same reachable route can be exercised with a known response-header difference, and the resulting page status can be captured repeatedly without sharing state between workers. The [multi-account isolation documentation](https://botbrowser.io/docs/identity/multi-account-isolation/) describes the context boundary. For CORS validation, BotBrowser can execute the owned control/candidate comparison, but it cannot configure the API response or circumvent the browser sharing rule.
BotBrowser does not author or deploy CORS headers, grant server authorization, repair a third-party API, change a page's origin, circumvent browser CORS enforcement, or prove that a remote business operation completed. It also cannot turn an opaque response into a readable one. Server configuration, CDN behavior, credentials, CSP, Permissions Policy, and application commit state remain responsibilities of the systems that own them. Treat the browser observation as one bounded signal in a larger authorization and delivery test.
A repeatable operating checklist
1. Declare the page origin, API origin, method, request headers, credential mode, and expected application result.
2. Check DNS, TLS, proxy, and route reachability before interpreting browser policy errors.
3. Capture the request origin, preflight (when present), status, CORS headers, `Vary`, and cache layer.
4. Separate CORS from CSP, Permissions Policy, user permission, server authorization, and business completion.
5. Use a fresh owned fixture with bounded eight-second navigation and five-second status waits; close the context in cleanup.
6. Keep network errors, navigation failures, status timeouts, preflight failures, and CORS blocks as distinct result types.
The standards remain the source of truth: consult the [Fetch CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol), [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), [MDN CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), and [MDN Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) when browser support or deployment details change. CORS is precise when its evidence is kept precise: reachability first, response sharing second, and application meaning last. Teams should also document which component owns each header. The API owner owns Allow-Origin and preflight behavior; the platform owner owns TLS, routing, proxy, and cache configuration; the web application owner owns Fetch mode, credential mode, error presentation, and retry behavior. A browser test should name these owners in its result rather than assigning every failure to the frontend. When a deployment changes an origin, CDN, authentication gateway, or service worker, rerun the control and candidate cases with a fresh context and preserve the captured request and response headers. Compare the preflight and actual request separately, because a successful preflight does not prove that the actual response was authorized or readable. For credentialed flows, use synthetic identities and a short-lived test cookie, and remove the fixture data after the run. Keep production observability focused on aggregate policy failures instead of collecting response bodies or private account details. This evidence model gives incident responders a bounded next action: repair the network, response policy, cache, embedding policy, or application branch that owns the observed result.


When an incident spans several teams, keep the evidence packet small and explicit. Include the page origin, API origin, request method, requested headers, credential mode, preflight response, actual response, cache headers, and visible application state. This lets an API owner reproduce the header decision while a frontend owner reproduces the browser branch. It also prevents a successful network trace from being reported as proof that a user was authorized. A CORS test should never scrape private response data to infer whether another account exists. Use synthetic records, short-lived cookies, and an owned endpoint that can be reset after every attempt. The browser can report that a response was exposed or withheld; only the application can report that a business operation was accepted. Keeping those statements separate makes alerts actionable and keeps a rollback focused on the policy that actually changed.
**Author:** BotBrowser Team
Read the [cross-origin isolation guide](/en/blog/cross-origin-isolation-and-shared-memory-requirements/) and [Permissions Policy guide](/en/blog/permissions-policy-for-embedded-browser-features/) for adjacent browser boundaries.

## Sources

- Fixture URL (example only): https://api.example.test/cors-fixture`
- Fixture URL (example only): https://app.example.test/candidate`
- Fixture URL (example only): https://app.example.test/control`
- Fixture URL (example only): https://app.example.test`


- [Fetch CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol)
- [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [MDN CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
- [MDN Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy)
#CORS#Cross-Origin Requests#Fetch#Web Security

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.