Cookie Defenses Alone Arent Enough

A real session cookie and a real POST endpoint were all it took to move money out of an account that was never compromised. SameSite narrows that gap. It doesn't close it.

July 25, 20265 min read3 / 5

The last post listed what a CSRF attack needs on paper. Seeing it actually work against a real transfer endpoint is a different kind of convincing.

Building the Attack Against Meridian Bank

I put together a small fictional banking app, Meridian Bank, to see this for myself. Peter logs in, and Meridian shows him an account balance of $1,000 with a form to transfer money to another user.

He sends $10 to Denver, a coworker he owes lunch money. The balance drops to $990, a transfer confirmation shows up, everything about this is completely intended behavior.

Then Peter gets sent a link to a meme. He clicks it, expecting a picture. Nothing loads.

He shrugs it off and checks his Meridian balance a minute later out of habit.

His balance now reads $740.

What Actually Happened

The meme page was never hosted anywhere near Meridian's domain. It didn't need to be.

Buried in the page was a hidden form, invisible, auto-submitted the instant the page loaded.

HTML
<form id="take-the-money-and-run" action="https://meridianbank.example/transfer" method="POST"> <input type="hidden" name="to" value="attacker-account" /> <input type="hidden" name="amount" value="260" /> </form> <script> document.getElementById("take-the-money-and-run").submit(); </script>

Peter's browser did exactly what browsers are built to do. It saw a POST request headed to meridianbank.example, and it attached Peter's Meridian session cookie automatically, the same way it would for a request Peter typed in himself. Meridian's server had no way to tell this request apart from a legitimate one, because at the HTTP level, there wasn't a difference.

No password was guessed. No cookie was stolen. A real, valid session simply got used somewhere it was never meant to be used. That's the entire attack, proven end to end on a working transfer endpoint.

What Gets Genuinely New Here: SameSite=None

The SameSite fundamentals, None versus Lax versus Strict, site versus origin, already got covered earlier in this course. What's worth adding now is why None still exists at all, given how permissive it is.

None used to be the default for every cookie on the web, before browsers changed course. Today it's the opposite: something a developer has to opt into deliberately, and the main reason anyone still reaches for it is cross-site ad tracking.

An ad network's whole business model depends on recognizing the same user across completely unrelated sites, which means its session cookie has to follow that user across the entire internet, not just its own domain. That's precisely the behavior Lax and Strict were built to stop, which is why ad trackers are stuck depending on None.

There's a real cost attached to choosing it, though: SameSite=None requires the cookie to also carry Secure, meaning HTTPS-only, as of modern browser enforcement. That single requirement broke a meaningful chunk of the ad-tracking ecosystem overnight, because a surprising number of trackers were still running on plain HTTP when the enforcement landed.

The Two-Minute Window

There's one narrow exception worth knowing about, mostly because it explains behavior that looks like a bug the first time you see it.

Some OAuth flows rely on a roughly two-minute grace window right after a cookie is set, where a freshly issued cookie behaves more permissively than its declared SameSite setting would otherwise allow. That back-and-forth handoff between two different origins during an OAuth login needs a brief moment of flexibility to complete, and browsers carved out this window to make that possible without forcing every OAuth integration onto None.

This is a narrow, low-risk exception, not a loophole worth exploiting. An attacker would need a victim to log in and land on a malicious page within roughly two minutes of that exact cookie being set, a timing window tight enough that it's not a practical attack surface for anyone.

The Point That's Easy to Miss: Lax Still Lets GET Through

Here's the detail that matters most for everything in post one of this chapter.

SameSite=Lax, the modern default, still sends the cookie on cross-site GET requests that count as top-level navigation: a clicked link, or a form that navigates the page instead of firing quietly in the background. What Lax actually blocks is cross-site subresource requests: images, background CSS, iframes, anything loaded silently without the user navigating anywhere.

Lax blocks background image and CSS-triggered GET requests as subresource loads, but still sends the cookie for a clicked link or a GET form that navigates the top-level page. ExpandLax blocks background image and CSS-triggered GET requests as subresource loads, but still sends the cookie for a clicked link or a GET form that navigates the top-level page.

That distinction matters more than it looks. The 2010 Twitter worm spread through links people clicked, which is a top-level navigation, and that exact vector still works under a modern Lax default. The Netflix DVD queue bug, delivered through an <img> tag, is the kind of subresource load Lax was actually built to stop.

SameSite narrows the attack surface. It does not close it. A GET endpoint that changes state is still dangerous today, Lax default or not, the moment the delivery mechanism is a link instead of an image.

What This Sets Up

Meridian's transfer endpoint had a real cookie and no unpredictable parameter guarding it. That third ingredient from the last post is still sitting there, wide open, and fixing it is what actually closes this hole.

The Essentials

  1. A real session cookie plus a real POST endpoint is enough for CSRF. No stolen credentials, no guessed password, required.
  2. SameSite=None used to be the default and is now something you opt into, mostly for cross-site ad tracking that needs a cookie to follow a user across the whole internet.
  3. None requires Secure, HTTPS-only, as of modern browser enforcement, which broke ad trackers still running on plain HTTP.
  4. A roughly two-minute grace window lets some OAuth flows behave more permissively right after a cookie is set. It's a narrow exception, not a practical attack surface.
  5. SameSite=Lax still sends the cookie on cross-site GET requests that are top-level navigation, like a clicked link. It blocks subresource loads like images and background CSS.
  6. SameSite narrows the CSRF attack surface. It does not close it. The fix still has to come from somewhere else.

Next up: the fix that actually closes the gap, by finally giving the third ingredient the unpredictability it's been missing.

Further Reading and Watching