~/learn / web-vulnerabilities / csrf
Cross-Site Request Forgery (CSRF)
How a malicious site makes a logged-in browser send requests the user never intended, why ambient cookie authority is the root cause, what SameSite does and does not cover, and how tokens and origin checks close it.
Cross-site request forgery abuses one browser behavior: cookies are ambient authority. Once you are logged in to bank.test, your browser attaches that session cookie to every request to bank.test, no matter which site caused the request. A malicious page you visit can tell your browser to POST to bank.test, and the cookie rides along automatically. If the bank authenticates on the cookie alone, it cannot distinguish the forged request from a genuine one, because at the byte level they are identical.
The attacker never sees the response and never steals the cookie. They do not need to. CSRF is a write primitive: it makes the victim perform a state-changing action (transfer money, change an email, add an admin) while logged in.
The anatomy of an attack
A vulnerable endpoint that changes the account email on a plain form POST:
<!-- hosted on evil.test, auto-submits when the victim opens the page -->
<form action="https://bank.test/account/email" method="POST">
<input type="hidden" name="email" value="[email protected]">
</form>
<script>document.forms[0].submit()</script>
The victim opens the page (from an ad, an email link, an iframe). The form submits to bank.test, the session cookie is attached by the browser, the server sees a valid session, and the email changes. The attacker then uses password reset to take the account. GET-based actions are even easier to forge, which is the first reason state changes must never happen on GET.
Why the same-origin policy does not save you
A common misconception is that the same-origin policy or CORS blocks this. They do not. The same-origin policy restricts a page from reading a cross-origin response. It places almost no restriction on sending a cross-origin request. A form can POST anywhere, an image can GET anywhere. CORS governs whether JavaScript may read a response, so adding CORS headers does nothing to stop a request that the attacker never needed to read. CSRF lives entirely in the gap between “can send” and “can read.”
SameSite cookies: real progress, real gaps
The SameSite cookie attribute tells the browser when to attach a cookie on cross-site requests. It is the biggest structural change to this class of bug, and most browsers now default unmarked cookies to Lax.
SameSite=Strict: never sent on any cross-site request, even top-level navigation. Strong, but it logs the user out visually when they follow a link to your site, so it is awkward for session cookies.SameSite=Lax(the common default): sent on top-level navigations that use a safe method (clicking a link, a top-level GET), withheld on cross-site POSTs, iframes, and background requests. This kills the classic auto-submitting POST form above.SameSite=None: always sent, and must be paired withSecure. Required for legitimate cross-site cookies, and a return to full CSRF exposure.
The gaps are what make SameSite a strong default rather than a complete fix:
Laxstill allows cross-site top-level GET requests to carry the cookie, so any state-changing GET endpoint remains forgeable. Keep writes off GET.- Attacks from the same site but a different origin (a subdomain you do not fully control, or a sibling app) are same-site, so SameSite does not apply. A single XSS or an attacker-controlled subdomain reopens the door.
- Some older clients and embedded webviews do not enforce it. Defense in depth still matters.
Treat SameSite as a valuable backstop that shrinks the attack surface, not as a reason to drop tokens on sensitive actions.
The primary fix: unpredictable tokens
The durable defense is to require a secret value on every state-changing request that an attacker cannot know or guess, because the same-origin policy prevents their page from reading it out of yours.
- Synchronizer token (the standard): the server generates a random token tied to the session, embeds it in every form, and rejects any POST whose token is missing or wrong. The attacker’s page cannot read the token out of
bank.test, so it cannot forge a valid request. This is what Railsprotect_from_forgery, Django’s{% csrf_token %}, and Spring Security give you by default. - Double-submit cookie: the token is sent both as a cookie and as a request header or body field, and the server checks they match. It needs no server-side state, which is why APIs like it, but it must be implemented carefully. If an attacker can set a cookie on the victim (through a subdomain or a cookie-injection bug), they can forge both halves, so the token should be signed or bound to the session rather than a bare random value.
- Custom header requirement: for JSON APIs, require a header such as
X-Requested-Withthat a cross-site HTML form physically cannot set. A simple form can only send a narrow set of content types and no custom headers, so requiring a custom header forces any caller through a CORS preflight, which a malicious origin cannot pass. Pair with a strict CORS allowlist.
# Rails enables synchronizer tokens by default on non-GET requests.
# The failure mode to review for is a controller that opts out:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token # justified only for
# endpoints authenticated some other way, such as a signed payload
end
Verify the origin, too
Modern browsers send Origin on all cross-origin requests and on same-origin POSTs. On sensitive endpoints, reject requests whose Origin (falling back to Referer) is not your own site. This is cheap, stateless, and catches forged requests even when a token check is somehow bypassed. Do not rely on it alone, because privacy tools and some proxies strip Referer, but as a second gate it is excellent.
Where CSRF still bites in a token-protected app
- Login CSRF: forcing a victim to log in as the attacker, so the victim’s activity accrues to an account the attacker controls. The login form needs CSRF protection too, which teams often forget because “logging someone in” sounds harmless.
- Logout CSRF: annoying rather than dangerous, but still worth a token on anything that changes state.
- Method-override and framework quirks: hidden
_method=DELETEfields, JSON endpoints that also accept form-encoded bodies, and CORS misconfigurations that reflect any origin with credentials. Each reopens the hole. - GraphQL and REST over cookies: any cookie-authenticated API is a CSRF target. Prefer
Authorization: Bearertokens held in memory for these, because a header the browser does not attach automatically cannot be forged.
Field notes: finding and breaking CSRF defenses
The first question on any state-changing request is “what actually authorizes this, and can I replay it from another origin?” You strip things one at a time to find the real guard:
- Delete the token entirely. Many apps check a token only if present. Remove the
csrf_tokenparameter (not blank it, remove it) and see if the request still succeeds. This is one of the most common real findings. - Swap the token for one from a different session or a static value. If the server accepts any well-formed token, it is not bound to your session.
- Change the method. A
POSTguard that does not coverGETfalls to<img src=/account/delete?id=5>. TryGET,PUT, and method-override headers (_method=POST,X-HTTP-Method-Override). - Strip the
Content-Type. Token checks sometimes ride on JSON parsing; sending the body astext/plainorapplication/x-www-form-urlencodedcan skip them while still reaching the handler.
Field notes: SameSite is not the fix people think
SameSite=Lax (now the browser default) blocks cross-site POST with cookies but still sends cookies on top-level GET navigations, so any state change reachable by GET is still CSRF-able. And Lax has a two-minute window: a cookie set without an explicit SameSite is treated as “Lax but allow unsafe top-level cross-site requests for 120 seconds after it is set,” which has been abused in real chains. SameSite=None (required for legitimate cross-site use) removes the protection entirely and relies wholly on tokens. Method-based and subdomain-based bypasses matter too: a cookie scoped to .example.com is same-site with every subdomain, so XSS or an open redirect on any subdomain re-enables CSRF against the parent.
Field notes: the auto-submit PoC and clickjacking cousin
The working proof of concept is a one-page HTML file hosted anywhere, which auto-submits on load:
<form action="https://bank.test/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit()</script>
For JSON-only endpoints, try the text/plain form trick (<input name='{"to":"attacker","amount":5000}'> with no value and enctype="text/plain"), which reconstructs a JSON-ish body some parsers accept. Burp’s CSRF PoC generator builds these from a captured request in one click. When framing is the goal instead, test clickjacking (missing X-Frame-Options/frame-ancestors) to trick a user into clicking the real button on the real site inside a transparent iframe, which sidesteps tokens because the request is genuine.
Code review checklist
- Confirm every state-changing route is non-GET and requires a CSRF token or a non-forgeable header.
- Find every
skip_before_action :verify_authenticity_token(and the equivalent in your framework) and confirm each one has an independent authentication mechanism. - Check session cookies carry
SameSite=LaxorStrict,HttpOnly, andSecure. - Check the CORS configuration does not reflect arbitrary origins with
Allow-Credentials: true. Inspect response headers with the header analyzer. - Confirm the login and password-change flows are themselves protected.
Practice on PortSwigger’s CSRF labs, which cover token validation flaws, SameSite gaps, and the Referer-based bypasses against a target built to be attacked legally.