TL;DR
Cross-Site Request Forgery exploits the trust between a browser and an authenticated session. A hidden form on an attacker's page can silently transfer money, change account settings, or even log you into the attacker's profile all while you browse completely unaware.
This post breaks down how CSRF works (with code), walks through real incidents that hit YouTube, Wikipedia, and ING Direct, and covers the lesser-known Login CSRF variant. Then it dives into every major defense: CSRF tokens, Referer and Origin validation, custom headers, SameSite cookies, and when each one matters, ending with a cheat sheet for quick reference.
Inspired by the paper "Robust Defenses for Cross-Site Request Forgery" by Barth, Jackson, and Mitchell (Stanford University).
You've probably encountered CSRF on a security checklist somewhere. It sits right next to XSS and SQL injection, one of those acronyms you know you should care about. But have you ever stopped to think about why it works so well, or why it's stuck around this long despite being discovered over two decades ago?
The answer is simple: CSRF exploits trust, not code. It doesn't need a vulnerability in your server. It doesn't need JavaScript. It just needs your browser to do what it was designed to do: send cookies with requests.
What CSRF Actually Is
By the way, it's sometimes pronounced as seasurf
Picture this: you're logged into your bank in one browser tab. In another tab, you click a link that loads a page containing a tiny, invisible form. The form targets your bank's transfer endpoint. The moment the page loads, JavaScript submits that form. Your browser, being the obedient citizen it is, attaches your banking cookies to the request. The transfer goes through. You never saw the form. You never clicked anything that looked suspicious. But ₹10,000 just left your account.
That's CSRF in a nutshell. Cross-Site Request Forgery doesn't trick you into doing something; it tricks your browser into doing something on your behalf. The browser can't tell the difference between a form you filled out and one an attacker embedded on a page you happened to visit.
The Anatomy of an Attack
Let's strip it down to the absolute basics. Say your bank has a transfer endpoint that accepts POST requests:
form action="https://bank.com/transfer" method="POST"
input type="hidden" name="to" value="attacker_account"
input type="hidden" name="amount" value="10000"
form
script
documentforms0submit
script
That's it. No exploitation of a server-side bug. No XSS payload. No phishing email with a convincing login page. Just a form that submits itself, and a browser that happily sends your cookies along for the ride.
The bank's server receives the request, sees valid session cookies, and processes the transfer. As far as it knows, you initiated this action. That's the fundamental problem CSRF attacks: the server has no way to distinguish between a request you made and a request someone else triggered using your credentials.
When CSRF Hit the Real World
This isn't just a textbook scenario. Some of the biggest platforms on the internet have fallen victim to straightforward CSRF attacks.
YouTube (2008). An attacker could embed a hidden form on any external page. When a logged-in YouTube user visited that page, the form would silently add a video to their Favorites, subscribe them to channels, or add friends. No clicks required. The attack worked because YouTube's action endpoints accepted POST requests without any token validation. The victims were unknowingly promoting attacker content using their own authenticated sessions.
Wikipedia (2007). An attacker crafted a page that, when visited by a logged-in Wikipedia admin, silently submitted a form changing their preferences. The modified preferences injected malicious scripts into the admin's edits, which then spread to other users. CSRF became the propagation vector for a worm that turned one admin's compromised session into a distribution channel.
ING Direct (2008). The online banking portal had no CSRF protection on its money transfer form. Visiting a malicious page while simultaneously logged into ING Direct could drain your account. The only user action required was loading the page. That's it.
Each of these was a textbook CSRF exploit. No advanced techniques. No zero-days. Just a form and a browser trusting the wrong page.
Login CSRF: The Overlooked Variant
Most CSRF discussions focus on performing actions on behalf of an authenticated user. But there's a subtler variant that often gets overlooked: Login CSRF.
Instead of making you do something, Login CSRF silently logs you into the attacker's account. You continue browsing, thinking you're in your own session, while everything you do gets recorded under their identity.
Why this matters
Imagine shopping on Amazon while unknowingly logged into the attacker's account:
- Every product you browse, every review you read, gets saved to their browsing history.
- Anything you add to a wishlist ends up on their list.
- They can log in later and reconstruct exactly what you were looking at, as if they'd been watching over your shoulder.
You didn't lose money. You didn't lose access to your account. But you lost something equally valuable: your privacy.
How it works
If a login form accepts POST requests without CSRF protection, an attacker can inject a form like this:
form action="https://example.com/login" method="POST"
input type="hidden" name="username" value="attacker@example.com"
input type="hidden" name="password" value="notsosecure"
form
script
documentforms0submit
script
The browser sends the request with the attacker's credentials. You're now inside their account, and you have no idea.
The fix is straightforward: apply the same CSRF protections to your login form that you'd apply to
any state-changing endpoint. Origin validation is especially effective here because it works
without a session, costs nothing to implement, and blocks Login CSRF entirely. Most modern
frameworks handle this out of the box—Django's @csrf_protect on login views is a good example.
The Defense Toolkit
So how do you actually protect against CSRF? There are several layers you can apply, and the best approach is to combine them.
CSRF Tokens
The classic defense. The server generates a cryptographically random token tied to the user's session. Every form includes this token as a hidden field. On submission, the server validates the token before processing the request.
Here's what that looks like in Express:
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString("hex");
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
And the validation on the receiving end:
app.post("/transfer", (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send("CSRF validation failed");
}
// process transfer...
});
For stateless APIs without server-side sessions, the Double Submit Cookie pattern works well: the server sets a CSRF token as a cookie, and JavaScript reads it and sends it back as a header. The server checks that both values match. An attacker can't read the cookie from a different origin, so they can't forge the header.
// Client-side
const token = getCookie("csrf-token");
fetch("/api/transfer", {
: "POST",
: { "X-CSRF-Token": token },
: "include",
});
A few things to watch out for:
- Login and logout forms are easy to forget—no session exists yet to bind a token to.
- Never expose CSRF tokens via GET endpoints or query parameters.
- Bind tokens to session IDs using HMAC to prevent token reuse across users.
Referer and Origin Validation
The Referer header tells the server where a request came from. You can reject requests that
don't originate from your domain:
app.post("/transfer", (req, res) => {
const referer = req.headers"referer"
if (!referer?.startsWith("https://yourbank.com/")) {
return res.status(403).send("Forbidden");
}
});
The Referer header has some baggage though—it leaks the full URL, and browsers sometimes strip
it for privacy reasons. The Origin header is cleaner: it only includes the origin, no path, no
query string. Browsers send it reliably with POST requests, and it's enough to validate where the
request came from.
app.post("/login", (req, res) => {
const origin = req.headers"origin"
if (origin !== "https://yourbank.com") {
return res.status(403).send("Cross-origin login denied");
}
// authenticate...
});
Pro tip: Use
Originvalidation for login forms. There's no session yet, so tokens aren't available. It's a zero-state, header-only defense that blocks Login CSRF entirely.
Custom Request Headers
For API requests, you can require a custom header that cross-origin form submissions can't set:
fetch("/api/transfer", {
: "POST",
: { "X-Requested-By": "XMLHttpRequest" },
: "include",
});
This works because cross-origin requests with custom headers trigger a CORS preflight. The browser sends an OPTIONS request first, and unless the server explicitly permits the custom header, the actual request is blocked. A malicious form or script can't set custom headers at all.
The trade-off is that all state-changing requests must go through JavaScript. Traditional HTML form submissions won't work with this approach.
SameSite Cookies
The most impactful CSRF defense came from browser vendors, not application developers. The
SameSite attribute tells the browser to only send a cookie when the request originates from the
same site:
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly| Attribute | Behavior | Best for |
|---|---|---|
SameSite=Lax | Cookie sent for top-level navigations (clicks, form GETs) but not cross-origin POSTs. Default in Chrome, Firefox, Edge since ~2021. | General web apps |
SameSite=Strict | Cookie never sent cross-origin, not even for navigation. | Password change, account deletion |
SameSite=None; Secure | Cookie sent cross-origin. Must also set Secure. | Embedded widgets, payment iframes |
With SameSite=Lax as the default, most classic CSRF attacks simply stop working. The browser
refuses to attach the session cookie to that hidden form submission. But you still need to be
careful about routes that accept GET for state changes (Lax allows top-level GET navigations),
services that intentionally set SameSite=None for cross-origin embedding, and legacy browsers
that don't support SameSite at all.
The Complete Defense Lifecycle
Here's how all these pieces fit together in a modern web application:
- User logs in, and the server issues a session cookie with
SameSite=Laxset. - The browser automatically sends this cookie only with same-site requests.
- For state-changing endpoints, the server also requires a CSRF token in the request body or header.
- If an attacker's page tries to submit a hidden form, the browser may still attach the cookie (for top-level navigations), but the missing CSRF token causes the server to reject the request.
- For API endpoints, custom headers trigger CORS preflights that block cross-origin requests entirely.
- Origin validation provides an additional safety net, especially for login forms where no session exists yet.
Pro tip: Layer your defenses.
SameSite=Lax+ CSRF tokens covers more edge cases than either alone. Don't rely on a single mechanism.
Quick Reference
| Technique | Best for | Effort |
|---|---|---|
| CSRF tokens (session-bound) | Server-rendered apps | Medium |
| Double Submit Cookie | SPAs / stateless APIs | Low |
Origin validation | Login forms, public endpoints | Low |
SameSite=Lax cookie | All apps | One line |
| Custom request headers | API-only apps | Low |
Rules of thumb:
- Layer your defenses.
SameSite=Lax+ CSRF tokens covers more edge cases than either alone. - Never mutate state on GET. Browsers prefetch
<img>,<link>, and<script>tags—a GET based action could fire without anyone clicking anything. - Use
SameSite=Strictfor sensitive actions like password resets, email changes, and account deletion. - If your framework has built-in CSRF protection (Django, Rails, Laravel, Spring), use it. Don't roll your own unless you have a specific reason.
X-Frame-Options: DENYhelps with clickjacking, a related but distinct attack worth addressing at the same time.
Final Thoughts
CSRF has survived this long because it attacks something fundamental about how the web works: the browser's job is to send cookies, and the server's job is to trust them. That trust model is what makes the web functional, and CSRF is the price we pay for it.
The key takeaways:
- CSRF exploits trust, not code. It doesn't need XSS, injection, or any server-side vulnerability. Just a browser doing what it was told.
- Real incidents hit the biggest names. YouTube, Wikipedia, and ING Direct all fell victim to straightforward CSRF attacks with no sophisticated exploitation involved.
- Login CSRF is overlooked but invasive. Attackers can silently log you into their account and reconstruct your activity without stealing anything directly.
- Defense in depth is essential.
SameSite=Laxcookies, CSRF tokens, and Origin validation each cover different edge cases. Layer them. - Modern browsers help, but don't rely on defaults.
SameSite=Laxis the default now, but legacy browsers and intentionalSameSite=Nonestill need explicit protection.
It's one of those things you set up once, understand once, and then never worry about again because your framework has already thought about it for you.