CSRF: The Attack That Makes Your Browser Betray You 🕵️♂️💸
CSRF: The Attack That Makes Your Browser Betray You 🕵️♂️💸
True story (sort of): A developer ships a banking API. It's behind login, uses HTTPS, and even has a fancy rate limiter. Feels secure, right? Then a user visits a sketchy "FREE iPhone 15 GIVEAWAY" page and — without clicking anything — silently transfers $10,000 to an attacker's account.
No password stolen. No SQL injection. No XSS. Just a browser doing exactly what browsers are designed to do: send cookies with every request.
Welcome to Cross-Site Request Forgery (CSRF) — where your own browser becomes the weapon. 🪖
What Is CSRF, Exactly? 🤔
CSRF is an attack where a malicious website tricks your browser into making authenticated requests to another website — using the cookies your browser already has stored.
Here's the mental model:
- You log into
bank.com→ browser stores a session cookie - You visit
evil-memes.com(in another tab) - That page silently fires a request to
bank.com/transfer - Your browser helpfully attaches the
bank.comcookie - The bank says "authenticated request, processing!" 💸
The server can't tell the difference between you clicking "Transfer" and evil-memes.com forcing your browser to do it. Both requests look identical from the server's perspective.
A CSRF Attack in the Wild 🌊
Let's say bank.com has this vulnerable endpoint:
POST /transfer
Cookie: session=abc123
amount=5000&to_account=ATTACKER_ACCOUNT
The attacker creates this innocent-looking page:
<!-- evil-memes.com/free-iphone.html -->
<html>
<body onload="document.forms[0].submit()">
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="amount" value="5000" />
<input type="hidden" name="to_account" value="ATTACKER_ACCOUNT" />
</form>
<h1>Loading your free iPhone... please wait! 🍎</h1>
</body>
</html>
The moment you land on that page — the form auto-submits. Your browser sends the POST request to bank.com with your valid session cookie attached. The server processes it. Done. Gone. 💸
No JavaScript prompt. No warning. Just a loading spinner while your money disappears.
Why Cookies Are the Problem 🍪
Browsers have a fundamental behavior: cookies are sent with every matching request, regardless of which page triggered it.
This made sense in 1995 when the web was simpler. Today it means:
You're logged into: Gmail, GitHub, Stripe, your bank, your company dashboard
Attacker can potentially trigger: password changes, OAuth grants,
payment processing, admin actions — all by tricking you to load one page
That .com domain restriction protects the response (cross-origin reads are blocked by CORS), but it does NOT stop the browser from sending the request with cookies. The damage is done on arrival, before any response is read.
How to Fix It: CSRF Tokens 🛡️
The standard defense is a synchronizer token — a secret value the server embeds in every form and then validates on submission.
The server generates a unique token per session:
// Laravel automatically handles this with @csrf
// But here's the underlying concept:
// On page load: store a random token in the session
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Embed it in the form
echo '<input type="hidden" name="_token" value="'
. $_SESSION['csrf_token'] . '">';
// On POST: validate it
if (!hash_equals($_SESSION['csrf_token'], $_POST['_token'])) {
http_response_code(403);
die('CSRF validation failed');
}
Laravel makes this trivially easy:
<form method="POST" action="/transfer">
@csrf {{-- Generates hidden _token input automatically --}}
<input name="amount" value="5000">
<button type="submit">Transfer</button>
</form>
The evil form on evil-memes.com can't forge this token — it has no way to read your bank.com session (blocked by same-origin policy). So its auto-submitted form arrives without a valid token and gets rejected. 🎯
The Modern Defense: SameSite Cookies 🍪🚫
Modern browsers support the SameSite cookie attribute, which tells the browser: don't send this cookie with cross-site requests.
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
Three flavors:
| Value | Behavior |
|---|---|
Strict |
Cookie never sent cross-site (even when following a link) |
Lax |
Cookie sent for top-level navigations (GET), blocked for POST |
None |
Old behavior — sent everywhere (requires Secure) |
Lax is now the browser default for cookies without an explicit SameSite attribute. This broke a huge class of CSRF attacks automatically! But Lax still allows GET-based CSRF, and old browsers don't support it.
Best practice — layer both defenses:
# FastAPI / Python example
from fastapi import Cookie, HTTPException
import secrets
# Set a SameSite=Strict session cookie
response.set_cookie(
key="session",
value=session_id,
httponly=True,
secure=True,
samesite="strict" # Nuclear option — great for banking!
)
# AND still validate CSRF tokens for state-changing endpoints
def validate_csrf(
x_csrf_token: str = Header(None),
session_token: str = Cookie(None)
):
stored = get_session_csrf_token(session_token)
if not secrets.compare_digest(x_csrf_token or "", stored or ""):
raise HTTPException(status_code=403, detail="CSRF validation failed")
SameSite alone is great, but CSRF tokens are your safety net for older browsers and edge cases.
CSRF in APIs: A Different Story 📡
Good news: If your API uses Authorization: Bearer <token> headers instead of cookies, you're naturally immune to CSRF. Custom headers can't be set by cross-origin form submissions or fetch() calls without CORS preflight.
// This is CSRF-safe — requires explicit JS, can't be forced by a form
fetch('https://api.myapp.com/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer eyJhbGci...', // Custom header = CSRF-safe
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 5000 })
});
But watch out for cookie-based SPAs. If your React/Vue app uses cookies for auth (common with httpOnly tokens), you still need CSRF protection on your API endpoints.
The pattern I use: Double Submit Cookie — put a CSRF token in both a cookie AND a request header. The server verifies they match. Cross-origin requests can't read the cookie value to set the header, so the attack fails.
Quick CSRF Defense Checklist ✅
Before shipping any form or state-changing endpoint:
- Add CSRF tokens to ALL forms (or use a framework that does it automatically — Laravel's
@csrf, Django's{% csrf_token %}, Rails'form_tag) - Set
SameSite=StrictorSameSite=Laxon session cookies - Set
HttpOnlyandSecureon session cookies - For APIs: prefer header-based auth (Bearer tokens) over cookies
- If using cookies in APIs: implement Double Submit Cookie pattern
- Verify
Origin/Refererheaders on sensitive endpoints as an extra layer - Never use GET requests for state-changing operations (deleting, transferring, updating)
The Bottom Line 🎯
CSRF is one of those attacks that feels almost unfair — your server code is fine, your database is clean, your passwords are hashed — and yet your users get compromised because of how browsers work.
The defenses are well-understood and easy to implement. Modern frameworks handle the basics automatically. The real danger is forgetting — building a "quick internal API" without CSRF protection, or adding a new endpoint outside your framework's middleware stack.
Every state-changing request deserves a CSRF check. Period.
Your browser is loyal to whoever set the cookie last. Make sure the attacker can't give it instructions. 🔐
Dealing with auth and security architecture? Connect on LinkedIn — always happy to talk web security!
Want to see real-world secure patterns? Check my GitHub for projects with proper CSRF handling baked in.
P.S. — Check your forms right now. If you don't see a hidden _token field or an X-CSRF-Token header in your network tab, you might have a problem. 🔍
P.P.S. — Yes, "SameSite=Lax is the default now" means most modern browsers protect you somewhat. But "somewhat" isn't good enough for a banking app. 😅