Difficulty: Practitioner

Introduction

CSRF is what happens when a website trusts a request just because it arrives with a valid session cookie, without checking whether the user actually meant to send it.
This path covers how that trust gets abused, how apps try to defend against it with tokens, SameSite cookies and Referer checks, and how each of those defenses commonly falls apart.

What Makes CSRF Possible

A request is forgeable when three things line up at once:

  1. The action is worth forging - changing an email, a password, a permission, transferring funds, anything that matters to an attacker.
  2. Cookies are the only proof of identity - no other mechanism ties the request to the user who’s supposed to be making it.
  3. Nothing in the request is unpredictable - if the attacker can guess every parameter, they can build the request themselves without ever seeing the victim’s screen.

If any one of these is missing, CSRF doesn’t work. Miss the third one and even a change-password form needs the current password, which the attacker won’t have.

Anatomy of an Attack

Say changing an email address looks like this on the wire:

text
1
2
3
4
5
6
POST /email/change HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Cookie: session=yvthwsztyeQkAPzeQ5gHgTvlyxHfsAfE

email=wiener@normal-user.com

No token, no secondary check, just a cookie. An attacker can reproduce this request from any page on the internet by hosting a self-submitting form:

1
2
3
4
5
6
7
8
9
10
<html>
<body>
<form action="https://vulnerable-website.com/email/change" method="POST">
<input type="hidden" name="email" value="pwned@evil-user.net" />
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>

The victim just has to be logged in and visit that page. Their browser attaches the session cookie automatically (this is exactly why cookies alone are a bad identity check), and the server processes the request as if the real user submitted it.

If the vulnerable action accepts GET, you don’t even need a hosted page, a single URL does the job:

text
1
<img src="https://vulnerable-website.com/email/change?email=pwned@evil-user.net">

Burp Suite Professional has a built-in Generate CSRF PoC option (right-click any request → Engagement tools). It builds the auto-submit HTML for you and strips the cookie header, since the browser adds that part on its own.

Breaking CSRF Token Defenses

The standard fix is a CSRF token: a secret, unpredictable value the server hands out and then demands back on every state-changing request.

1
<input required type="hidden" name="csrf" value="50FaWgdOhi9M9wyna8taR1k3ODOR8d6u">

That’s solid if implemented correctly. In practice, most real-world bypasses come down to sloppy validation logic:

  • Method-dependent checks - the token is verified on POST but the same endpoint happily accepts GET, so just switch methods and drop the token entirely.
  • Presence-dependent checks - the token is verified if it’s there, but its absence is treated as fine. Strip the parameter, not just its value.
  • Token not bound to the session - the server keeps one global pool of valid tokens instead of tying each token to the user who requested it. Log in as yourself, grab a fresh token, hand that same token to the victim.
  • Token bound to the wrong cookie - the token is checked against a separate csrfKey cookie instead of the session cookie. If you can plant any cookie in the victim’s browser (even from a sibling subdomain), you can pair it with a token you already know.
  • Double-submit tokens - the app just checks that the token in the request matches the token in a cookie, with no server-side record of what was issued. If you can set cookies on the victim, you don’t even need a valid token, just invent one and put the same value in both places.

A token is only spent once it’s submitted and accepted. Loading the page that displays it (a plain GET) does not burn it, that’s the gap that makes the “steal your own token, feed it to the victim” trick work.

Breaking SameSite Defenses

SameSite is a cookie attribute that tells the browser when to withhold a cookie from cross-site requests. Three levels:

Value Behavior
Strict Cookie never sent cross-site, period.
Lax Cookie sent cross-site only on top-level GET navigations (clicking a link). Blocked on cross-site POST, iframes, images, scripts.
None No restriction at all (requires Secure).

Since 2021, Chrome defaults undeclared cookies to Lax. That kills the classic auto-submitting POST form, but it leaves gaps:

Method laundering. If the server doesn’t actually care whether the request arrived as GET or POST (some frameworks let you override the method via a hidden _method field), a plain top-level GET navigation still carries a Lax cookie:

1
2
3
<script>
document.location = 'https://vulnerable-website.com/account/transfer-payment?recipient=hacker&amount=1000000';
</script>

On-site gadgets. Strict normally survives this, since the browser only cares who initiated the request. The workaround is splitting one cross-site jump into two hops:

  1. The attacker page sends the victim to a legitimate page on the real site, one that reads a parameter and hands it to window.location (a DOM-based open redirect gadget). This first hop is cross-site, but it doesn’t need auth, so no cookie required.
  2. That page’s own script then fires the second navigation. As far as the browser is concerned, this jump started on the target site, so it’s same-site, and the Strict cookie rides along.
1
2
3
// vulnerable page on victim.com, reads ?url= and redirects
const target = new URLSearchParams(location.search).get('url');
window.location = target;
1
2
3
4
<!-- attacker's page -->
<script>
document.location = "https://victim-site/redirect?url=/my-account/change-email%3Femail%3Dhacker%40evil-user.net";
</script>

The browser never sees a single cross-site request landing on the sensitive endpoint, it sees two requests, and the second one looks like it came from the site itself.

Sibling domains. SameSite only cares about the site (eTLD+1), not the exact origin. Any XSS or open redirect anywhere on *.example.com can be leveraged to fire a same-site request against secure.example.com. The same logic extends to WebSockets, a cross-site WebSocket handshake is just CSRF wearing a different protocol.

The 120-second grace window. Chrome’s default Lax isn’t enforced for the first two minutes after a cookie with no explicit SameSite attribute is issued, to avoid breaking SSO redirects. If you can force the victim through a flow that re-issues their session cookie (an OAuth round-trip, for example) right before firing the attack, you land inside that window.

Breaking Referer-Based Defenses

Checking the Referer header is weaker than tokens and usually breaks in one of two ways:

  • Missing header is treated as valid. Force the browser to drop Referer entirely with a meta tag on the attacker’s page, and the check never runs:
    1
    <meta name="referrer" content="never">
  • Naive substring matching. If the server just checks that the domain starts with or contains the right value, both of these slip through:
    text
    1
    2
    http://vulnerable-website.com.attacker-website.com/csrf-attack
    http://attacker-website.com/csrf-attack?vulnerable-website.com
    Note: modern browsers strip the query string from Referer by default, so the second trick only works if the response serving the exploit sets Referrer-Policy: unsafe-url.

How To Prevent CSRF

  • Generate a real, unpredictable CSRF token, tie it to the user’s session server-side, and reject any request where it’s missing, wrong, or the method was switched to dodge the check.
  • Treat SameSite=Strict (or Lax at minimum) as defense in depth, not a replacement for tokens, gadgets and sibling domains can still slip past it.
  • Don’t rely on Referer as your only line of defense, it’s optional and attacker-influenced by design.

Summary

CSRF rarely fails because tokens or SameSite don’t exist, it fails because they’re wired up inconsistently: checked on one method but not another, tied to the wrong cookie, or trusted without being verified server-side. Auditing an app for CSRF is really just auditing every place validation could have been skipped.

As always, I scripted the lab solves instead of clicking through the UI, it’s a better way to actually internalize what each bypass depends on. Repo here: CSRF Scripts Repo

Have a nice day, and see you again in the next Article!
Any feedback in the comments section is very appreciated.
Thank you for your attention.