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.

ActionCross-origin behaviour
Sending a simple requestsent; the server receives and processes it
Reading the response bodyblocked unless the response carries a matching Access-Control-Allow-Origin
Reading a response headeronly the CORS-safelisted ones, unless Access-Control-Expose-Headers names others
Setting Cookie, Host or Origin from scriptnever allowed; these belong to the browser
Sending cookies cross-originonly 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.

ElementSafelisted valuesConsequence of leaving the list
MethodGET, HEAD, POSTPUT, PATCH or DELETE triggers a preflight
Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plainapplication/json triggers a preflight
HeadersAccept, Accept-Language, Content-Language, Content-Type, Rangeany custom header such as Authorization triggers a preflight
Readable response headersCache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragmaname 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-Origin may 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.
  • fetch defaults to credentials: '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 a SameSite=Lax cookie to a cross-site fetch at all.
  • XMLHttpRequest expresses the same thing as xhr.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.

Authentication, tokens and CSRF Debugging network problems

Last refreshed 2026-09-18.