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)
“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:
- Did the browser send the actual request?
- Did the API accept the identity and data permissions?
- Did the API perform the operation?
- 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)
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:
- Compare the page origin and API URL, including proxy and redirect behavior.
- If OPTIONS appears, compare the requested method and headers with the permissions. Do not stop at its status code.
- 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.
- For missing cookies, inspect storage, host, path, credentials mode, SameSite, HTTPS, and browser policy.
- If the body works but a header does not, check Expose-Headers and forbidden response headers.
- 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
- MDN: CORS, for the complete request exchange.
- WHATWG Fetch Standard, for precise credentials and wildcard rules.
- MDN: Allow-Headers and Allow-Credentials, for implementation checks.
- Web Dev Simplified: CORS introduction, for an introductory account of browser and server responsibilities before checking the official references in each section.