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.

evil.examplePOST /payyour APIyou didn't send itSameSite=Lax
Rick
Senior Security Engineer
May 5, 2026
5 min read

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 skip of 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.

csrfcookiessessionssamesiteweb-security
Written by
Rick
Senior Security Engineer · Firetrail review team
More Security