Topic: Web platform

CORS from scratch: separate request delivery, cookies, and response access

A practical guide to origins, OPTIONS preflights, fetch credentials, cookies, Allow and Expose headers, and browser debugging. Understand why an API can finish while JavaScript cannot read its response.

Animated meme (expand/collapse)
The API reports success while the page reports failure. Check response access before retrying a completed operation. · Source: GIPHY

“It works in Postman. Why does the browser reject it?” CORS gets confusing when fetch options, API response headers, and cookie attributes become one pile of settings.

They control different stages. This guide follows a page at https://app.example.test calling an API at https://api.example.test. The URLs, token, and data are illustrative, not a service you can call.

I would identify the failing stage before changing a setting. JavaScript losing access to a response does not prove that the request never arrived or that the API rolled back its work.

1. Who does the browser protect?

An untrusted page should not freely read private API data from another site where you are logged in. The same-origin policy restricts reading across origins. CORS lets the API explicitly permit particular page origins through response headers.

Postman, curl, and backend programs do not operate under the same page-reading restriction. Their success proves that the API answered that call, not that the browser received permission. MDN: same-origin policy

Separate the questions:

  1. Did the browser send the actual request?
  2. Did the API accept the identity and data permissions?
  3. Did the API perform the operation?
  4. Did the browser expose the response to JavaScript?

The last answer can be no while the earlier answers are yes. For an order or form submission, a frontend error is not enough evidence that a retry is safe.

2. An origin is not a complete URL

For ordinary HTTP(S) URLs, compare the scheme, hostname, and effective port. Paths, queries, and fragments do not change the origin.

Compared with https://app.example.test Same origin? Reason
https://app.example.test/settings?tab=2 Yes Path and query do not matter
https://app.example.test:443/ Yes 443 is the default HTTPS port
https://app.example.test:8443/ No Different port
http://app.example.test/ No Different scheme
https://api.example.test/ No Different hostname

Two localhost services on different ports are also different origins. You can inspect the normalized value:

new URL("https://app.example.test:443/settings?tab=2").origin;
// "https://app.example.test"

Use that form for Access-Control-Allow-Origin, without a path or trailing slash. MDN: URL.origin

3. Which side owns each setting?

Location Example Purpose
Frontend fetch option credentials: "include" Permit eligible credentials across origins
Frontend request header Authorization: Bearer demo-token Send explicit authentication information
API response header Access-Control-Allow-Origin Permit the page origin to read the response
Cookie attributes HttpOnly, SameSite, Secure Restrict cookie access and transmission

Putting Allow-Origin in a request does not grant server permission. Ordinary page scripts also cannot freely forge browser-managed Origin or Cookie headers. The request diagrams below include headers the browser generates, not just those a script can set.

4. Follow an ordinary GET

Start with a public product list:

const response = await fetch("https://api.example.test/products", {
  credentials: "omit",
});

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const products = await response.json();

There are no extra headers or request body. The ordinary CORS exchange is:

Browser sends GET → API processes it → Browser checks CORS → JavaScript reads the result

The API can restrict access to one origin:

Access-Control-Allow-Origin: https://app.example.test
Content-Type: application/json

Public data requested without credentials can also use Access-Control-Allow-Origin: *. Each response uses one origin or an applicable wildcard, not a comma-separated origin list. To support several websites, validate the request origin against a trusted list and return the matching origin. Do not reflect arbitrary origins. MDN: Allow-Origin

Requests that avoid preflight are commonly called simple requests. The name does not promise safety or remove the response-access check. MDN: CORS

5. OPTIONS examines the outgoing request

The browser uses OPTIONS to ask permission for some requests before sending them. Common triggers include PUT, PATCH, DELETE, a script-set Authorization or custom header, and a request Content-Type of application/json.

Avoiding ordinary preflight requires meeting all relevant conditions, including GET, HEAD, or POST and safelisted headers with permitted values. Common eligible request MIME types are application/x-www-form-urlencoded, multipart/form-data, and text/plain. Checking the header name alone is insufficient. MDN: safelisted request headers

JSON appears in different directions:

Setting Meaning
Request Content-Type: application/json The body I send is JSON
Request Accept: application/json I prefer a JSON response
Response Content-Type: application/json The API returned a JSON body

An API returning JSON does not itself trigger preflight. Sending a POST with a JSON Content-Type is a request condition that does.

6. Authorization can preflight a GET with no body

const response = await fetch("https://api.example.test/profile", {
  headers: { Authorization: "Bearer demo-token" },
  credentials: "omit",
});

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const profile = await response.json();

Authorization is not ordinarily safelisted. Without reusable preflight permission, the browser first sends:

OPTIONS /profile HTTP/1.1
Origin: https://app.example.test
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization

The last line contains a header name, not the Bearer token value. The API can answer:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Headers: Authorization

After approval, the browser sends the actual GET with the token. The API validates its value and data permissions, then supplies CORS headers on the actual response.

Allow-Headers permits a header name; it does not make a token valid. Authorization is also a wildcard exception: list it explicitly rather than relying on Allow-Headers: *. The omit option above does not strip an Authorization header explicitly supplied by the script. That differs from automatically attached cookies. MDN: Allow-Headers, Fetch Standard

Keep the specification separate from implementation: browsers do not consistently implement this wildcard exception. Acceptance in one version does not establish standards compliance or portability. List Authorization explicitly instead of relying on permissive behavior. MDN compatibility data

7. Preflight success is only the first check

Preflight needed, with no reusable permission
  ↓
OPTIONS asks about origin, method, and headers
  ├─ Rejected: the subsequent actual request is not sent
  └─ Approved: send request → API acts → Check actual response CORS
                                          ├─ Allowed: page can read
                                          └─ Rejected: page cannot read; work may be done

A 204 status alone does not provide complete permission. A PUT still needs matching method permission, such as Access-Control-Allow-Methods: PUT. Preflight cannot replace Allow-Origin or applicable credentials permission on the actual response.

Apply the origin policy to error responses too. A 401, 500, or gateway failure without the right CORS headers can make the browser’s CORS message obscure the upstream problem.

8. Sending cookies and reading responses are separate

Credentials mode controls browser-managed credentials and whether response Set-Cookie headers are respected:

Value Behavior
same-origin Default; use them only for same-origin requests
include Allow them across origins, subject to cookie and browser rules
omit Do not use them, even for same-origin requests

A cookie-session API can use:

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

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const me = await response.json();

This does not prove a cookie exists, is eligible, or represents a valid login. It supplies one necessary frontend condition. MDN: Request.credentials

For cross-origin include mode, the API needs:

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

Combining Allow-Origin: * with Allow-Credentials: true is still invalid. An absent cookie does not make the wildcard acceptable: the check depends on credentials mode, not merely whether a Cookie header happened to exist. HttpOnly cannot repair that combination. MDN: Allow-Credentials, Fetch: CORS and credentials

Include alone does not necessarily preflight an ordinary GET. When preflight is needed, an ordinary preflight carries no login cookie, but the server must still permit the subsequent include-mode request. Keep authentication and data authorization on the actual endpoint.

Animated meme (expand/collapse)
Looking familiar is not permission. Include mode, cookie eligibility, and the API's origin approval still need separate checks. · Source: GIPHY

9. HttpOnly, SameSite, and Secure have different jobs

Set-Cookie: session=demo-value; Path=/; Secure; HttpOnly; SameSite=Lax

This is an illustration, not a login configuration every system should copy.

Condition What it checks
Host or Domain Whether the cookie applies to the API host; absent Domain usually means host-only
Path and expiry Whether the path matches and the cookie remains valid
Secure Secure transport restrictions; use HTTPS in production
HttpOnly Scripts cannot directly read the cookie value
SameSite Whether the site context permits attaching the cookie
Browser policy Whether third-party cookies face restrictions

HttpOnly does not stop eligible cookies from automatically accompanying fetch. Cross-origin is not always cross-site. The app and api examples ordinarily have different origins but share a schemeful site. Site compares scheme and registrable domain rather than the full hostname and port. MDN: Site

For a genuinely cross-site fetch, include cannot override explicit SameSite=Lax. SameSite=None requires Secure, and third-party cookie policies still apply. Do not remove HttpOnly or change every cookie to None simply to make login appear to work.

Set-Cookie also cannot be exposed to frontend scripts. Listing it in Expose-Headers does not make response.headers.get("Set-Cookie") reveal it. Network visibility, cookie storage, and script access are distinct. MDN: Set-Cookie

10. Keep Allow-Headers and Expose-Headers in the right direction

Suppose a request sends X-Client-Version and the response returns X-Next-Cursor:

Access-Control-Allow-Headers: X-Client-Version
Access-Control-Expose-Headers: X-Next-Cursor

The first permits an outgoing request header. The second lets JavaScript read an incoming response header through response.headers.get("X-Next-Cursor"). A readable body does not imply every header is readable, and DevTools can show more than the script receives. MDN: Expose-Headers

Use this as a reference rather than memorizing the names:

Header Owner Purpose
Origin Browser Describe the page origin
Access-Control-Request-Method / Headers Browser preflight Describe the intended method and non-safelisted header names
Access-Control-Allow-Origin API response Approve the origin
Access-Control-Allow-Methods / Headers API preflight response Permit methods and headers that need approval
Access-Control-Allow-Credentials Applicable API response Permit response sharing in include mode
Access-Control-Expose-Headers Actual API response Expose additional response headers to scripts

11. Missing OPTIONS and no-cors are different issues

Access-Control-Max-Age caches preflight permission, not the API body. A matching request can reuse approval without sending OPTIONS again, subject to browser limits. An unapproved new header cannot borrow permission from an unrelated cached approval. MDN: Max-Age

If responses vary with Origin, account for those variants in caches, for example by adding Origin to the existing Vary value. Vary neither grants CORS permission nor makes private data safe for public caching. MDN: Vary

mode: "no-cors" does not make a JSON API readable. An opaque cross-origin response exposes status 0 and a null body rather than the original contents. Calling text and then JSON.parse cannot recover the hidden body. Request methods and headers are restricted too. MDN: Response.type

12. CORS does not authenticate users or cover all CSRF risks

Origin permission, authentication, and data authorization each need their own checks. Origin is not a user identity. Non-browser callers can construct the header, so matching it does not establish permission to read private data.

CSRF can use automatically attached login information to cause an operation the user did not intend. The attacker may not need to read the response. Missing CORS permission therefore does not establish CSRF protection. Use appropriate controls for the system, including CSRF tokens, origin checks, and cookie policies. MDN: CSRF

13. A practical debugging order

I would gather Network evidence before adding wildcards:

  1. Compare the page origin and API URL, including proxy and redirect behavior.
  2. If OPTIONS appears, compare the requested method and headers with the permissions. Do not stop at its status code.
  3. If the actual request appears, inspect its status, server logs, and response CORS headers. A write error on the page does not prove the write failed.
  4. For missing cookies, inspect storage, host, path, credentials mode, SameSite, HTTPS, and browser policy.
  5. If the body works but a header does not, check Expose-Headers and forbidden response headers.
  6. Include gateway and error paths, not just successful responses.

With valid CORS, HTTP 401 or 500 normally gives fetch a Response rather than rejecting solely because of the status. Keep the response.ok check in the examples. A caught TypeError alone cannot distinguish CORS from a network failure. MDN: Using Fetch

A development proxy can make requests appear same-origin to the browser. It is useful locally but does not prove production cross-origin permissions. Test allowed and denied origins, cookie behavior, and script-visible bodies and headers in a real browser before release.

What I learned

  • I would separate delivery, API execution, and response access before deciding whether a failed write is safe to retry.
  • Include is a credentials mode, not an override for cookie rules or wildcard restrictions. HttpOnly controls direct access.
  • I would check header direction first. Authorization needs explicit Allow-Headers; a response cursor needs Expose-Headers.
  • After fixing CORS, authentication, data permissions, and CSRF still need independent verification.

This guide covers ordinary browser fetch calls to HTTP APIs. It is not a complete treatment of WebSocket, special origins, cross-origin isolation, or private network access. The snippets illustrate frontend and HTTP behavior, not a complete production authentication implementation.

References