Sessions, Cookies, and the Requests You Didn't Send
Your browser attaches cookies to requests you never meant to make. A working tour of CSRF, httpOnly and SameSite, and why a state-changing GET is a trap waiting to spring.
Here's a question I like to ask engineers who are new to web security: when you're logged into your bank in one tab and browsing a random forum in another, what stops that forum's page from making requests to your bank — as you?
The uncomfortable answer is: less than you'd hope, and historically, almost nothing. Any page can trigger a request to any origin — an image tag, a form submission, a script-initiated POST. And for most of the web's history, the browser would attach your bank cookies to those requests automatically, because that's what cookies did: they rode along with every request to their domain, no matter who initiated it. That gap — the browser authenticates requests the user never intended — is cross-site request forgery, and understanding it properly makes a whole cluster of session-security rules stop feeling like arbitrary checklist items.
The forged request, concretely
Say your app has an endpoint that changes the account email, and it accepts a simple form POST authenticated by a session cookie. A malicious page elsewhere on the internet embeds an auto-submitting form pointed at that endpoint, with the attacker's email in the field. A logged-in user of your app visits that page — via a phishing link, a compromised ad, a forum post — and their browser dutifully submits the form, cookie attached. Your server sees a syntactically perfect, fully authenticated request. The user saw nothing.
Note what the attacker never obtained: the cookie itself. They can't read it. They don't need to. They just need the browser to use it. That's why CSRF is sometimes called a "confused deputy" attack — your user's browser is the deputy, holding real credentials and taking instructions from the wrong principal.
The defenses, and what each one actually covers
SameSite cookies
Modern browsers let a cookie declare when it's willing to travel cross-site:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
SameSite=Lax — the default in modern Chrome and the right explicit choice almost everywhere — stops the cookie from riding along on cross-site POSTs, which kills the classic auto-submitting-form attack outright. Strict goes further but breaks the "click a link to your app from an email and arrive logged in" flow, so most apps land on Lax.
But read the fine print on Lax, because it's load-bearing: the cookie still travels on cross-site top-level GET navigations. SameSite=Lax protects you only if your GETs are safe — which brings us to the trap in this article's subtitle.
Never mutate on GET
If your app has an endpoint like GET /projects/42/delete — and legacy codebases have these, usually born as a quick link in some admin page — then SameSite=Lax does nothing for it. A plain <a> tag or an <img src=...> on any page on the internet can trigger it with cookies attached. The HTTP spec calls GET "safe" precisely so that browsers, prefetchers, link-preview bots, and proxies can issue GETs freely. A state-changing GET breaks that contract, and everything downstream that trusted the contract becomes your attacker's delivery mechanism. I've seen a chat app's link-preview crawler delete records by unfurling a URL someone pasted.
In review, any route where a GET handler writes to the database is a finding — not a style nit — because it silently exempts itself from the CSRF protections everything else relies on.
CSRF tokens
The classic defense predates SameSite and still matters: the server embeds a random token in each form or hands it to the SPA, and every state-changing request must echo it back in a place cookies can't reach — a form field or a custom header. A forged cross-site request can't include the token because the attacking page can't read it. Rails, Django, and friends do this for you; the failure mode I see in review is not missing tokens but disabled ones:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token # fine here...
end
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token # ...but why here?
end
Skipping CSRF checks is legitimate for endpoints authenticated by something that doesn't auto-attach — signed webhook payloads, bearer tokens in headers. It's a hole when the endpoint still honors session cookies. Every skip deserves the question: "what authenticates this endpoint, and does that credential travel automatically?" If the answer is "the session cookie," the skip just reopened CSRF for that route. Assistants and copy-paste both propagate these skips readily, because the line looks like boilerplate; it isn't.
Belt-and-suspenders is the right posture here: SameSite=Lax and tokens. Browser coverage has edge cases, subdomain takeovers can muddy "same-site," and defense in depth is cheap when the framework does the work.
httpOnly, Secure, and what they're for
While we're stamping attributes on the session cookie: HttpOnly keeps JavaScript from reading it, which means an XSS bug on your site can't simply exfiltrate the session (the attacker can still act via the XSS, but they can't walk off with a reusable credential — that difference shortens incidents). Secure keeps it off plaintext HTTP. Neither defends against CSRF — httpOnly cookies attach to forged requests just fine — but they close the neighboring exits, and all three attributes should appear together so reviewers can stop thinking about it.
One more session habit that pays off: rotate the session identifier on login and privilege changes, and make logout actually invalidate the session server-side rather than just deleting the cookie. A session that outlives logout is a stolen-laptop gift.
A short field guide for reviewers
When a diff touches routes, sessions, or cookie config, this is my sweep:
- Does any GET handler mutate state? That's the trap; convert it to POST/DELETE before discussing anything else.
- Session cookie set with
HttpOnly; Secure; SameSite=Lax(or Strict)? All three, explicitly — don't inherit defaults you haven't verified. - Any new
skipof CSRF protection: what authenticates that endpoint, and does the credential auto-attach? Cookie-authenticated skips are findings. - SPA talking to a cookie-authenticated API: are state-changing requests carrying a token or custom header the server actually validates?
- Logout and privilege changes: is the session invalidated and rotated server-side?
CSRF is one of those vulnerabilities where the browser vendors have genuinely improved the defaults, and it's tempting to file it under "solved." But the protections are conditional — Lax only holds if your GETs are honest, tokens only hold where nobody skipped them — and conditions are exactly what erodes quietly over a hundred pull requests. Review the conditions, not just the config.