CSRF (Cross-Site Request Forgery)
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:
- The action is worth forging - changing an email, a password, a permission, transferring funds, anything that matters to an attacker.
- Cookies are the only proof of identity - no other mechanism ties the request to the user who’s supposed to be making it.
- 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:
1 | POST /email/change HTTP/1.1 |
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 | <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:
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
POSTbut the same endpoint happily acceptsGET, 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
csrfKeycookie 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 | <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:
- 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. - 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
Strictcookie rides along.
1 | // vulnerable page on victim.com, reads ?url= and redirects |
1 | <!-- attacker's page --> |
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
Refererentirely 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:Note: modern browsers strip the query string from
text 1
2http://vulnerable-website.com.attacker-website.com/csrf-attack
http://attacker-website.com/csrf-attack?vulnerable-website.comRefererby default, so the second trick only works if the response serving the exploit setsReferrer-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(orLaxat minimum) as defense in depth, not a replacement for tokens, gadgets and sibling domains can still slip past it. - Don’t rely on
Refereras 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.





