SameSite Cookies
why your login works locally but dies in prod
Web developmentweb development / security
The scenario
Your app logs in perfectly on localhost. You ship it: the frontend goes on my-app.vercel.app, the API on my-api.onrender.com. Suddenly users log in, then get bounced straight back to the login screen. The Set-Cookie in DevTools looks fine. But the very next request goes out with NO cookie. What happened?
The mental model
Every cookie carries a SameSite attribute. It tells the browser whether to attach the cookie on requests that originate from a DIFFERENT site than the one that set it. Three values:
- Strict — sent ONLY when the request comes from the exact same site. Follow a link from another site into yours? No cookie on that first navigation.
- Lax (the modern default) — sent on same-site requests AND top-level navigations (clicking a link, typing the URL). NOT sent on cross-site sub-requests: fetch/XHR, images, iframes.
- None — sent on every request, cross-site included. But browsers REQUIRE Secure with it, so it only works over HTTPS.
Back to the bug
On localhost everything shared one origin, so "same-site" was always true and the cookie rode along on every fetch. In prod your SPA on vercel.app calls an API on onrender.com via fetch() — a cross-site sub-request. With the default Lax, the browser silently drops the cookie. Login "succeeds," the session cookie gets set, but the next authenticated fetch sends nothing → 401 → back to login.
The nuance that trips intermediates
"Same-site" is NOT "same-origin." It's based on the registrable domain (eTLD+1), ignoring subdomain, port, and scheme. So app.hamzawi.dev and api.hamzawi.dev are same-SITE (both hamzawi.dev) even though they're different origins — Lax happily sends the cookie between them. The break only happens across different registrable domains, like vercel.app → onrender.com. There you need:
Set-Cookie: session=...; SameSite=None; Secure; HttpOnly
Plus CORS with Access-Control-Allow-Credentials: true and credentials:'include' on the fetch. Miss any one of those three and the cookie vanishes.
Bonus: SameSite is your cheap CSRF defense
A classic CSRF attack relies on the browser auto-attaching your session cookie to a forged cross-site request. SameSite=Lax breaks that for sub-requests for free — which is exactly why it became the browser default. Setting SameSite=None re-opens that door, so pair it with real CSRF tokens.
Rules of thumb
- Frontend + API on the same registrable domain → Lax is fine (and safer).
- Truly cross-site API → SameSite=None; Secure + CORS credentials.
- Always add HttpOnly (JS can't read it) and Secure (HTTPS only).
Your exercise
Open your app in DevTools → Application → Cookies. Find your session cookie and read its SameSite column. Now open the Network tab, trigger an authenticated fetch, and inspect that request's Cookie header. Is the cookie actually being sent? If your frontend and API sit on different registrable domains and SameSite isn't None, it won't be — and you've just found a latent prod bug.