Cookies, CORS, Same-Origin
Where cookies live, why CORS fails, how to fix it.
HTTP is stateless. Each request stands alone. So how does a server know "this is the same user who logged in two minutes ago"? Cookies. And since cookies and credentials can be sent across origins, the browser enforces a strict separation called the same-origin policy, with a controlled escape hatch called CORS. Get these two right and most mysterious "why is my fetch failing" bugs disappear.
Cookies: little notes the browser carries around
When a server wants to remember something about you, it sends a Set-Cookie response header. The browser stores it. On every subsequent request to that site, the browser automatically attaches it back via the Cookie header.
# Server response
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
# Next request from the browser
Cookie: session=abc123Set-Cookie attributes - read them all carefully
Path=/- which URL paths the cookie is sent on. Defaults to the path the cookie was set from.Domain=example.com- which domain. Leaving it off scopes to the exact host; setting it widens to subdomains.Max-Age=3600orExpires=...- how long before the browser drops it. Without either, the cookie is a session cookie that dies when the browser closes.HttpOnly- JavaScript cannot read this cookie viadocument.cookie. Set this on session cookies. It blocks a huge class of XSS attacks that try to steal the session.Secure- the cookie is only sent over HTTPS, never plain HTTP. Mandatory for anything sensitive.SameSite- when should the browser send this cookie on cross-site requests? Three values:Strict- only when the request originates from the same site. Even clicking a link from email won't send the cookie. Safest, sometimes too restrictive.Lax- sent on top-level navigations (clicking a link) but not on cross-site subresources or background fetches. The modern default.None- sent on all cross-site requests. Must also be paired withSecure. Use this only when you genuinely need third-party cookies, like embedded widgets.
HttpOnly; Secure; SameSite=Lax. This blocks JS access (HttpOnly), forces HTTPS (Secure), and stops most CSRF (SameSite). Lax is fine for almost every app.The same-origin policy
An origin is the triple (scheme, host, port). These are different origins:
https://app.example.comhttp://app.example.com(different scheme)https://example.com(different host)https://app.example.com:8080(different port)
By default, JavaScript running on origin A cannot read responses from origin B. The browser sends the request, but it hides the response body from your code. This is the same-origin policy. It exists because your browser is logged into a lot of websites at once, and any random page you visit would otherwise be able to read your bank balance via a sneaky fetch.
<img>, <script>, <form> submissions, or CSS imports. Hence why we also need SameSite cookies and CSRF protection.CORS: the controlled escape hatch
CORS(Cross-Origin Resource Sharing) is how a server opts in to letting browsers on other origins read its responses. It's entirely a browser thing. Servers do not block cross-origin requests. Browsers do.
The server signals consent with response headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, DELETESimple requests vs preflighted requests
For "simple" requests (GET/HEAD/POST with safe headers and a basic content type), the browser just sends the request, then checks Access-Control-Allow-Originon the response. If it matches the page's origin (or is *), JS gets the response. Otherwise, the fetch promise rejects with a CORS error.
For anything else - PUT, DELETE, custom headers, Content-Type: application/json - the browser sends a preflight first. This is an OPTIONSrequest asking the server, "will you accept the real request I'm about to send?"
OPTIONS /api/articles HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization
Access-Control-Max-Age: 86400DELETE /api/articles/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer ...
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.comThe Access-Control-Max-Age tells the browser to skip the preflight for that long. A day is fine.
Sending cookies cross-origin
By default, browsers don't send cookies on cross-origin fetches. To opt in:
- On the client:
fetch(url, { credentials: 'include' }) - On the server, the response must include both:
Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: https://your-exact-origin(no*allowed when credentials are included)
- The cookie must be
SameSite=None; Secureto be sent cross-site at all.
Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true, the browser refuses both. The fix is to echo back the specific Origin header value (validated against a whitelist) and add Vary: Originso caches don't mix responses.Why CORS errors happen, and how to fix them
The cardinal mistake: thinking CORS is a server-side rejection. The server doesn't reject anything. The request reaches the handler, runs, returns 200. The browser then looks at the response headers, doesn't see the rightAccess-Control-Allow-Origin, and hides the response from your JS while logging a scary console message.
Common scenarios:
- "No Access-Control-Allow-Origin header" - your server doesn't set CORS headers. Add the middleware (in Express, the
corspackage; in Next.js, set them in the route handler or middleware). - "Response to preflight has invalid status" - your server returns 404 or 500 on
OPTIONS. Many frameworks need an explicit OPTIONS handler. - "The value of Allow-Origin must not be the wildcard '*' when credentials mode is 'include'" - switch from
*to the actual origin string. - Auth header is missing on the server - you sent
Authorizationfrom the client, but didn't list it inAccess-Control-Allow-Headers. The preflight fails and the real request never goes out.
Quiz
Which Set-Cookie attribute prevents JavaScript from reading the cookie?
Recap
- Cookies persist state across stateless HTTP requests. Set them with
HttpOnly; Secure; SameSite=Laxby default. - An origin is
(scheme, host, port). Same-origin policy stops one origin's JS from reading another's responses. - CORS is the server's way to say "I allow this origin." It's enforced by the browser.
- Anything beyond a basic GET/POST triggers an OPTIONS preflight. Handle it.
- For cross-origin cookies:
credentials: 'include'on the client,Access-Control-Allow-Credentials: true+ specific origin on the server, andSameSite=None; Secureon the cookie.