Same-origin policy, CORS and credentials
What the browser blocks and what it merely hides, how simple requests differ from preflighted ones, and what changes when cookies are involved.
What the same-origin policy really does
An origin is the scheme, host and port together. The policy does not stop a script from sending a cross-origin request — it stops the script from reading the response. That distinction explains both why CORS works and why it is not a server-side security control.
| Action | Cross-origin behaviour |
|---|---|
| Sending a simple request | sent; the server receives and processes it |
| Reading the response body | blocked unless the response carries a matching Access-Control-Allow-Origin |
| Reading a response header | only the CORS-safelisted ones, unless Access-Control-Expose-Headers names others |
Setting Cookie, Host or Origin from script | never allowed; these belong to the browser |
| Sending cookies cross-origin | only with an explicit credentials mode on both sides |
// same origin: CORS never enters the picture
await fetch('/api/me');
// cross-origin, credentials omitted by default
await fetch('https://api.example.com/me');
// cross-origin with cookies: the server must opt in too
await fetch('https://api.example.com/me', {
credentials: 'include',
headers: { 'X-Request-Id': crypto.randomUUID() }
});- A blocked response still reached the server. The write may have happened, so never treat a CORS error as proof that nothing changed.
- The error message is deliberately vague about what the server sent. The Network panel shows the request and the real headers; the console only says the response was withheld.
mode: 'no-cors'does not disable CORS. It produces an opaque response with a status of 0 and an unreadable body, useful only for fire-and-forget beacons.- A same-origin request to a different port is cross-origin. So is HTTP to HTTPS, and so is a redirect that lands on another host.
Simple and preflighted requests
A request is simple when the method is GET, HEAD or POST, the body is one of the three simple content types, and no header outside the safelist is set. Anything else triggers an automatic OPTIONS preflight before the real request.
| Element | Safelisted values | Consequence of leaving the list |
|---|---|---|
| Method | GET, HEAD, POST | PUT, PATCH or DELETE triggers a preflight |
| Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain | application/json triggers a preflight |
| Headers | Accept, Accept-Language, Content-Language, Content-Type, Range | any custom header such as Authorization triggers a preflight |
| Readable response headers | Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma | name others in Access-Control-Expose-Headers |
OPTIONS /me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-csrf-token
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Access-Control-Max-Age: 600
Vary: Origin⚠️
A preflight doubles the latency of every non-simple request until
Access-Control-Max-Age lets the browser cache the decision. On a slow mobile connection that is a visible delay on the first write of a session, which is exactly the moment a user notices.Cookies, credentials and the wildcard rule
Access-Control-Allow-Originmay never be*when credentials are included. The server has to echo the exact origin, which means it must never echo an origin it has not validated against an allowlist.- If the response varies by origin, it must send
Vary: Origin, or a shared cache will hand one site's response to another. fetchdefaults tocredentials: 'same-origin': cookies go to your own origin, not to the API host. Cross-origin cookies need'include'on the client and the allow-headers above on the server.- Cross-site cookies also need
SameSite=None; Secure. Chrome and Safari will not attach aSameSite=Laxcookie to a cross-site fetch at all. XMLHttpRequestexpresses the same thing asxhr.withCredentials = true.
// client side
await fetch('https://api.example.com/me', {
credentials: 'include',
headers: { Accept: 'application/json' }
});
// server side, in pseudo-headers
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
// a reverse proxy is the alternative when you cannot change the API
// the page calls /api/... on its own origin, and the proxy forwards to the API host
await fetch('/api/me', { credentials: 'same-origin' });FAQ
Why does the URL work in a browser tab but fail from fetch?
Typing a URL sends no
Origin header, so there is no CORS check. A script request includes one, and the server must answer with a matching Access-Control-Allow-Origin. The request itself probably succeeded; only the response was withheld from your code.Is CORS a security feature I can rely on?
It protects the user's browser session from being read by other origins. It is not an authorisation system: a server that processes the request without checking the caller is still exposed, because a non-browser client ignores CORS entirely.
Related
Authentication, tokens and CSRF Debugging network problems
Last refreshed 2026-09-18.