I was reviewing a web application during a security assessment when I spotted a fund transfer endpoint that accepted a plain GET request with no token validation. The session cookie was HttpOnly and Secure, the login page had rate limiting, the team had clearly put effort into security. But that one endpoint was wide open to CSRF. Honestly, it was one of those findings where you stare at it for a second and think, how is this still showing up in 2025? A single crafted link sent to a logged-in user would have silently transferred funds to an attacker-controlled account.
That kind of gap is what makes Cross-Site Request Forgery worth understanding properly, not just at a surface level. This post covers how CSRF actually works, where it has caused real damage, and how to prevent it in practice across different stacks.
What is a CSRF Attack?
CSRF is an attack where a malicious website or link causes a victim’s browser to send an authenticated request to a different website, without the victim knowing. The attack works because browsers automatically attach cookies to requests, including session cookies. If you are logged into your bank and your browser makes a request to the bank’s transfer endpoint, the bank sees a valid session and processes the request, even if you did not initiate it.
The attacker does not steal your credentials. They do not need to. They just need your browser to make a request while your session is active, and the server to process it without verifying you actually intended to send it.
Common actions attackers target with CSRF:
- Fund transfers or payment actions on banking and fintech apps.
- Email or password changes on account settings pages.
- Admin actions on CMS platforms (publishing content, changing roles).
- Social actions (follows, likes, posts) on platforms using cookie-based sessions.
How a CSRF Attack Works
The mechanics are straightforward once you see them laid out. Here is what happens during a typical GET-based CSRF attack:
The victim logs into bank.com. The server sets a session cookie:
Set-Cookie: session=abc123; Secure; HttpOnly The attacker hosts a page or sends an email containing a hidden request. The simplest version is an image tag pointing to a state-changing endpoint:
<img src="https://bank.com/transfer?amount=5000&to=attacker_account" width="0" height="0"> When the victim’s browser loads that page, it fetches the image URL. The browser attaches the session cookie automatically. The bank’s server receives a valid authenticated request and processes the transfer. The victim sees nothing.
POST-based CSRF is slightly more involved but still straightforward. The attacker uses an auto-submitting form:
<form id="csrf" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="amount" value="5000">
<input type="hidden" name="to" value="attacker_account">
</form>
<script>document.getElementById('csrf').submit();</script> Load that page in a browser with an active session on bank.com and the form submits silently. The key point is that none of this requires the attacker to know the victim’s password, intercept traffic, or be on the same network. All they need is a logged-in browser.
Real Cases Where CSRF Caused Damage
The Samy Worm, MySpace (2005) is still the most dramatic CSRF-driven incident on record. Samy Kamkar combined a stored XSS vulnerability with CSRF to create a self-propagating worm. When a logged-in MySpace user viewed Samy’s profile, the worm added Samy as a friend, copied itself to the victim’s profile, and then spread to every visitor of that profile. It infected over one million accounts in under 24 hours before MySpace took the site offline. The CSRF component was what made the action execute silently under the victim’s own session.
Netflix (2006): Researchers found that Netflix’s DVD queue management used GET requests with no CSRF protection. An attacker could craft a link that added or removed DVDs from a victim’s queue. More critically, the same weakness exposed account settings changes including the account email address, which could then be used for account takeover.
YouTube (2008): Google confirmed a CSRF vulnerability that allowed attackers to perform actions on a victim’s account, including adding videos to favourites and sending friend requests. Given YouTube’s scale at the time, the exposure surface was significant.
These examples share a common thread. The applications trusted the session cookie as sufficient proof of intent. They had no secondary mechanism to verify the request came from a page the user actually loaded on their own. Basic stuff, but easy to miss when you’re moving fast.
How to Prevent CSRF
1. CSRF Tokens
This is the primary defence and the one most frameworks implement by default. A CSRF token is a random, unpredictable value generated server-side and tied to the user’s session. It is embedded in every form or state-changing request. The server validates the token on submission. An attacker hosting a malicious page cannot read the token from your site due to the Same-Origin Policy, so they cannot include a valid one in a forged request.
Here is what it looks like in practice across three common stacks:
Django (Python) handles it automatically. Every form rendered through Django’s template engine gets the token injected:
<form method="post" action="/transfer/">
{% csrf_token %}
<input type="text" name="amount">
<input type="text" name="to_account">
<button type="submit">Transfer</button>
</form>
<!-- Django renders the token as: -->
<input type="hidden" name="csrfmiddlewaretoken" value="KbyUmhTLMpYj7CD2di7JKP1P3qmLlkPt"> Laravel (PHP) works the same way with the @csrf Blade directive:
<form method="POST" action="/transfer">
@csrf
<input type="text" name="amount">
<input type="text" name="to_account">
<button type="submit">Transfer</button>
</form>
<!-- Laravel renders: -->
<input type="hidden" name="_token" value="xIoSCt8mPZoT3R5jeT9kbkVLfMDKC4eV"> Express (Node.js) requires the csurf middleware (or its modern alternatives like csrf-csrf):
const { doubleCsrf } = require('csrf-csrf');
const { generateToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET });
// In your route handler:
app.get('/form', (req, res) => {
res.render('form', { csrfToken: generateToken(req, res) });
});
// In the form:
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// Protect POST routes:
app.post('/transfer', doubleCsrfProtection, (req, res) => {
// Token validated. Safe to process.
}); 2. SameSite Cookie Attribute
The SameSite attribute controls whether a cookie is sent with cross-site requests. It is one of the most effective CSRF mitigations available today and requires no changes to your application logic, only to how you set cookies.
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly Strict means the cookie is never sent on any cross-site request, including when a user clicks a link from an external site to your site. This is the most secure option but can break legitimate navigation flows, for example, a payment confirmation email linking back to your app.
Lax allows the cookie on top-level navigations (clicking a link) but blocks it on subresource requests like images, iframes, and form POSTs from other sites. This is the default in Chrome since version 80 (2020) and blocks the majority of CSRF attacks while preserving normal browsing behaviour.
None sends the cookie on all requests, including cross-site. This is required when your cookie legitimately needs to be sent in third-party contexts, such as embedded widgets or OAuth flows. When using SameSite=None, the Secure flag is mandatory or browsers will reject the cookie.
One important caveat: SameSite alone is not sufficient if you have subdomains. A request from evil.yoursite.com to app.yoursite.com may still be treated as same-site depending on the browser. Pair it with CSRF tokens for full coverage.
3. Validate Origin and Referer Headers
Every browser sends an Origin header with cross-origin POST requests, and a Referer header on most requests. Server-side, you can check these against your expected domain and reject anything that does not match.
# Python/Django example of manual Origin header check
def transfer(request):
origin = request.META.get('HTTP_ORIGIN', '')
if origin != 'https://bank.com':
return HttpResponseForbidden('Invalid origin')
# Process transfer This works as a secondary layer but should not be your only defence. Some privacy tools and corporate proxies strip these headers. In those cases, a legitimate request would fail the check. Use it alongside CSRF tokens, not instead of them.
4. Use POST, PUT, or DELETE for State-Changing Actions
GET requests should never change state. A GET request can be triggered by an image tag, a link, a browser prefetch, or a bot crawler. If your transfer endpoint responds to GET, it is trivially exploitable with a single <img> tag as shown above.
Move all state-changing actions to POST, PUT, or DELETE. These cannot be triggered by image tags or simple links. Combined with CSRF tokens and SameSite cookies, the attack surface drops significantly.
5. Double Submit Cookie Pattern
This technique is useful when you cannot store server-side session state, for example in stateless APIs. The server generates a random CSRF token, sends it as both a cookie and a value in the response body (usually a hidden form field or JSON response). On subsequent requests, the client sends the token in both the cookie and a request header or body parameter. The server checks that both values match.
# Server sets:
Set-Cookie: csrf_token=r4nd0mT0k3n; SameSite=Strict; Secure
# Client sends back in both:
Cookie: csrf_token=r4nd0mT0k3n
X-CSRF-Token: r4nd0mT0k3n
# Server validates: cookie value == header value An attacker cannot read the cookie value from JavaScript on a different origin due to the Same-Origin Policy, so they cannot replicate the matching header. This is the pattern used by many stateless API frameworks.
6. Custom Request Headers for AJAX and APIs
If your application uses AJAX for state-changing requests, requiring a custom header like X-Requested-With: XMLHttpRequest adds CSRF protection. Browsers enforce CORS preflight checks for custom headers on cross-origin requests. A forged cross-site request cannot add custom headers, so the server can reject anything missing them.
# Client sends:
POST /api/transfer HTTP/1.1
X-Requested-With: XMLHttpRequest
Content-Type: application/json
{"amount": 500, "to": "user123"}
# Server checks:
if request.headers.get('X-Requested-With') != 'XMLHttpRequest':
return 403 Note that this relies on CORS enforcement. If your server has a permissive CORS policy that allows cross-origin requests with credentials, this protection breaks down. Audit your CORS configuration alongside your CSRF defences.
7. Re-Authentication for High-Value Actions
For actions where the consequences of CSRF would be severe, such as wire transfers, changing account email, or deleting data, require the user to re-enter their password or complete a second factor before the action is processed. Even if every other CSRF defence failed, an attacker cannot satisfy a password prompt from a forged request.
This is the approach banks use for large transfers and why most platforms ask for your current password before letting you set a new one. It is an old idea, but it holds up.
CSRF vs. XSS: Where They Differ and Why It Matters
These two are frequently confused because they both involve browsers doing things users did not intend. The difference is in what the attacker controls and what they are after.
With CSRF, the attacker gets the victim’s browser to perform an action on a site the victim is authenticated to. The attacker does not see the response. They cannot read data. They can only trigger actions. A CSRF attack on a bank might transfer money, but the attacker cannot read your account balance through the same mechanism.
With XSS (Cross-Site Scripting), the attacker injects malicious script into a page that runs in the victim’s browser under the origin of the vulnerable site. Because the script runs in the correct origin, it can read cookies, DOM content, responses from API calls, and anything else the page can access. XSS is generally more powerful because it gives the attacker persistent code execution in the victim’s browser session.
The important operational point is that XSS can completely bypass CSRF tokens. If an attacker can inject a script into your page, that script runs in your origin, can read the CSRF token from the DOM, and can include it in forged requests. CSRF protection is rendered useless if you have an XSS vulnerability. Fix XSS first, then layer CSRF defences on top.
Why CSRF Still Appears in Production Systems
Modern frameworks handle CSRF by default, so you might wonder why it still shows up in assessments. In practice, I see it in a few recurring patterns:
APIs built without web security in mind. A team builds a REST API intended for a mobile app, uses token-based authentication (JWT in Authorization header), and correctly assumes CSRF is not a concern there. Later, a web frontend is built that authenticates via cookies for convenience. The CSRF protections are never added because the original API design did not account for cookie-based auth.
Framework protections explicitly disabled. Django and Laravel have CSRF middleware on by default. But developers sometimes add endpoints to the exemption list to solve a short-term problem (a webhook, an integration) and forget to add proper validation back.
Legacy code paths. The main application has CSRF tokens everywhere. But there is an old admin panel, a legacy reporting module, or a third-party integration endpoint that predates the framework migration and never got updated. I’ve flagged this exact pattern in at least three assessments this year alone.
Misconfigured CORS that undermines SameSite. A developer sets Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true thinking it makes the API more accessible. This combination is actually invalid and browsers will block it, but attempting it signals confused understanding of the security model that often accompanies other CSRF gaps.
How to Test for CSRF Yourself
The fastest way to check an endpoint is to intercept a state-changing request in Burp Suite, send it to Repeater, remove the CSRF token (or change it to an invalid value), and resubmit. If the server accepts it, the endpoint is vulnerable.
# Original request captured in Burp:
POST /account/update-email HTTP/1.1
Host: target.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
email=new@email.com&csrf_token=KbyUmhTLMpYj7CD2di7JKP1P3qmLlkPt
# Modified request in Repeater:
POST /account/update-email HTTP/1.1
Host: target.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
email=attacker@evil.com&csrf_token=INVALID
# If this returns 200 and changes the email, it is vulnerable. Burp Suite Pro also has an automated CSRF PoC generator under Engagement Tools that builds a working HTML proof-of-concept page for any captured request. For manual testing, PortSwigger’s CSRF testing guide is the most detailed publicly available resource.
For broader application security context, see OWASP’s CSRF page, which covers edge cases including login CSRF and multi-step attack flows.
Developer Checklist
- CSRF tokens are generated server-side, tied to the session, and validated on every state-changing request.
- Session cookies use
SameSite=Laxat minimum.Strictwhere the UX allows it. - No state-changing actions are reachable via GET requests.
- CORS policy is explicitly configured. No
Access-Control-Allow-Origin: *with credentials. - High-value actions (transfers, email changes, deletions) require re-authentication.
- CSRF exemption lists in framework middleware are reviewed and minimised.
- XSS vulnerabilities are addressed. CSRF tokens are not meaningful protection if XSS is present.