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).
Understanding and Defending Against CSRF
You've probably heard of CSRF, that acronym that pops up whenever someone talks about web security checklists. But have you really looked at what makes it dangerous, sneaky, and, at times, a pain to mitigate? Let's break it down in simple terms, with practical examples, and explore some modern defenses that make CSRF less terrifying.
What's CSRF?
By the way, it's sometimes pronounced as seasurf
Imagine you're logged into your bank website in one tab. In another tab, you're reading a spicy blog post that secretly contains a hidden form. That form silently submits a request to your bank's "transfer money" endpoint with your cookies attached transferring ₹10,000 to the attacker's account.
That's CSRF: Cross-Site Request Forgery. It tricks your browser into sending an authenticated request to a site without your knowledge.
A Very Basic Example
You're at a coffee shop, logged into your bank in one tab to pay utility bills. In another tab, you open what looks like an innocent tech blog. Hidden in its markup is a form like this, set to auto-submit the moment it loads:
<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>
document.forms[0].submit();
</script>
Your browser attaches your banking cookies to the request automatically. If the bank hasn't put CSRF protection in place, the transfer goes through and you don't notice a thing until it's too late.
Real Incidents
CSRF isn't just a theoretical threat it's bitten some of the biggest names on the web.
YouTube (2008). A hidden form on any external page could add a video to a logged-in user's
"Favorites", subscribe them to channels, or add friends all without a single click. Victims
unknowingly promoted attacker content using their own session. The attack worked because YouTube's
action endpoints accepted POST requests without any token validation.
Wikipedia (2007). An attacker crafted a page that, when visited by a logged-in Wikipedia admin, silently submitted a form changing their preferences. This turned into a CSRF worm: the modified preferences injected malicious scripts into the admin's edits, which in turn infected other users. CSRF became a propagation vector.
ING Direct (2008). The online banking portal had no CSRF tokens on its money transfer form. Visiting a malicious page while simultaneously logged into ING Direct could drain your account. The only user interaction required? Loading the page.
Each of these was a straightforward CSRF exploit no XSS, no sophisticated phishing. Just a hidden form and a browser doing exactly what it was told.
Login CSRF - The Lesser Known Evil
Not all CSRF attacks aim to steal or transfer something directly. Some are sneakier like Login CSRF, a lesser known but equally devious variation.
Instead of performing harmful actions, Login CSRF silently logs you into a website using the attacker's credentials. You continue browsing, unaware that you're now operating inside their session. Everything you do browsing, uploading, commenting is linked to them, not you.
Why is this bad?
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.
- Any item you save 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.
It's subtle, invisible, and invasive. You might not lose money but you definitely lose privacy.
How does this happen?
If a login form accepts credentials via POST without any CSRF protection (tokens, Referer checks, Origin validation), an attacker can inject a hidden form like:
<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>
document.forms[0].submit();
</script>
Once submitted, your browser obediently sends the request with the attacker's credentials and now you're inside their account.
How to prevent it: Apply the same CSRF protections to your login form as you would to any
state-changing endpoint. Origin validation is especially well-suited here it works without a
session, costs nothing to implement, and blocks Login CSRF entirely. Many frameworks already include
login CSRF prevention out of the box (e.g, Django's @csrf_protect on login views).
So How Do We Defend Ourselves?
Let's look at some standard (and not-so-standard) techniques.
1. Secret CSRF Tokens (Anti-CSRF Tokens)
The bread and butter. Here's how it works in practice:
- Server generates a cryptographically random token tied to the user's session.
- Every form rendered on the server includes the token as a hidden field.
- On submission, the server validates the token before processing the request.
A typical Express middleware:
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 check on form submission:
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 (no server-side sessions), use the Double Submit Cookie pattern: 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.
// Client-side
const token = getCookie("csrf-token");
fetch("/api/transfer", {
method: "POST",
headers: { "X-CSRF-Token": token },
credentials: "include",
});
The attacker can't read the cookie value from a different origin, so they can't forge the header.
Common gotchas:
- 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.
2. Referer Header Checks
The Referer header tells you where the request came from:
Referer: https://yourbank.com/accounts
Two validation strategies:
- Same-origin: Reject unless the origin in
Referermatches your domain exactly. - Same-site: Accept any request whose hostname shares your registered domain.
app.post("/transfer", (req, res) => {
const referer = req.headers["referer"];
if (!referer?.startsWith("https://yourbank.com/")) {
return res.status(403).send("Forbidden");
}
});
Caveats:
- Proxies and browser extensions often strip the
Refererheader. - It's most reliable over HTTPS, HTTP pages lose it entirely.
- A missing
Refererdoesn't mean an attack (user typing a URL, bookmark, etc.). Validate positively: reject wrong origins, but don't auto-reject missing headers.
3. Custom Request Headers (for API requests)
When using fetch or XMLHttpRequest, add a custom header:
fetch("/api/transfer", {
method: "POST",
headers: { "X-Requested-By": "XMLHttpRequest" },
credentials: "include",
});
Why this works: 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 via
Access-Control-Allow-Headers, the actual request is blocked. A malicious <form> or <script>
can't set custom headers at all.
Even a static value like X-Requested-By: XMLHttpRequest is effective the attacker can't add it
from a simple form submission.
Trade-off: All state-changing requests must go through JavaScript. Traditional HTML form submissions won't work with this approach.
Origin Validation: The Cleaner Alternative
The Referer header has baggage it leaks the full URL, and browsers sometimes strip it. Enter
Origin, which only includes the origin:
Origin: https://yourbank.com
No path. No query string. No privacy leak. Just enough to validate where the request came from.
Browsers send Origin reliably with POST requests. Server-side validation is a few lines:
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...
});
Why attackers can't spoof it: The browser controls the Origin header for form submissions.
JavaScript can set arbitrary Origin headers in fetch() calls, but those trigger CORS preflights
and unless your server explicitly whitelists the attacker's domain, they won't get through.
Pro tip: Use
Originvalidation for login forms as there's no session yet, so tokens aren't available. It's a zero-state, header-only defense.
The Game-Changer: SameSite Cookies
The most impactful CSRF defense came from browser vendors. The SameSite attribute tells the
browser: "only send this cookie when the request comes from my 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 browser behavior, most classic CSRF attacks simply stop working
the browser refuses to attach the session cookie to that hidden form submission.
What you still need to protect:
- Routes that accept
GETfor state changes (Lax allows top-level GET navigations). - Services that intentionally set
SameSite=Nonefor cross-origin embedding. - Legacy browsers that don't support
SameSite.
Best Practices - Cheat Sheet
| 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 (password resets, email changes, account deletion). - If your framework has baked-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.