webdev.complete
📨 HTTP Deep Dive
🌐The Web Beneath
Lesson 58 of 117
25 min

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.

bash
# Server response
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600

# Next request from the browser
Cookie: session=abc123

Set-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=3600 or Expires=... - 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 via document.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 with Secure. Use this only when you genuinely need third-party cookies, like embedded widgets.
The session-cookie checklist
For login/session cookies, always set: 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.com
  • http://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.

The same-origin policy applies to reading responses via JavaScript. It does not stop the browser from sending the request, and it does not apply to top-level navigations, <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:

bash
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, DELETE

Simple 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?"

1. preflight
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: 86400
2. the real request
DELETE /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.com

The 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: true
    • Access-Control-Allow-Origin: https://your-exact-origin (no * allowed when credentials are included)
  • The cookie must be SameSite=None; Secure to be sent cross-site at all.
Allow-Origin: * + credentials = silently broken
If you set 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:

  1. "No Access-Control-Allow-Origin header" - your server doesn't set CORS headers. Add the middleware (in Express, the cors package; in Next.js, set them in the route handler or middleware).
  2. "Response to preflight has invalid status" - your server returns 404 or 500 on OPTIONS. Many frameworks need an explicit OPTIONS handler.
  3. "The value of Allow-Origin must not be the wildcard '*' when credentials mode is 'include'" - switch from * to the actual origin string.
  4. Auth header is missing on the server - you sent Authorizationfrom the client, but didn't list it in Access-Control-Allow-Headers. The preflight fails and the real request never goes out.
Debugging trick
Open DevTools, Network tab, find the failing request. Check the Preflight (OPTIONS) request first. If thatfailed or didn't happen, the CORS headers on the preflight response are the problem. Only look at the main request once the preflight succeeds.

Quiz

Quiz1 / 4

Which Set-Cookie attribute prevents JavaScript from reading the cookie?

Recap

  • Cookies persist state across stateless HTTP requests. Set them with HttpOnly; Secure; SameSite=Lax by 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, and SameSite=None; Secure on the cookie.