CSRF: When Hackers Make Your Users Do Things They Didn't Mean To ๐ญ๐
Picture this: your user is logged into your app in one browser tab. In another tab, they're browsing a meme site. They click a funny image.
Somewhere in the background, your app just transferred $500 from their account.
They have no idea. You have no idea. The attacker is laughing.
Welcome to Cross-Site Request Forgery โ the attack that weaponizes your users' own trust against them. ๐
What Is CSRF, Actually? ๐ค
Here's the core idea: browsers automatically attach cookies to every request sent to a matching domain. If your user is logged into yourbank.com, and an evil site tricks their browser into sending a request to yourbank.com โ the browser helpfully includes the session cookie.
Your server sees a valid, authenticated request. It has no idea it was forged.
The anatomy of a CSRF attack:
- User logs into
yourbank.comโ session cookie stored in browser - User visits
evil-memes.comwhile still logged in - Evil page silently sends a request to
yourbank.com/transfer - Browser auto-attaches the session cookie
- Bank processes the request as legitimate
- Money gone. Chaos achieved. ๐ฅ
The cruel part? The user did nothing wrong. The server did nothing wrong (technically). The browser did exactly what it was designed to do.
The Attack in Code (Don't Be This Guy) ๐จ
Here's how embarrassingly simple a CSRF exploit is to craft:
<!-- On evil-memes.com โ loads silently when user visits -->
<img
src="https://yourbank.com/transfer?to=attacker&amount=500"
style="display:none"
width="0"
height="0"
/>
That's it. An <img> tag. The browser fires a GET request to your bank trying to load a "image". The bank processes it.
For POST requests, attackers use auto-submitting forms:
<!-- Auto-fires on page load, completely invisible -->
<form
id="csrf-form"
action="https://yourapp.com/settings/change-email"
method="POST"
>
<input type="hidden" name="email" value="[email protected]" />
</form>
<script>
document.getElementById("csrf-form").submit();
</script>
The user's browser submits this form with their real session cookie the moment they hit the page. Your app sees an authenticated POST request to change the email. It complies. Account hijacked. ๐
Real-World Damage This Causes ๐ฅ
CSRF isn't theoretical. Here's what gets exploited in the wild:
- Password/email changes โ attacker locks user out of their own account
- Fund transfers โ classic banking attack vector
- OAuth approvals โ user silently grants attacker's app full permissions
- Admin actions โ if an admin visits evil page, entire app config changes
- Account deletion โ scorched-earth trolling at scale
- Social actions โ silent follows, posts, DMs sent on user's behalf
And here's the kicker: any endpoint that trusts session cookies and doesn't verify request origin is vulnerable. APIs included.
How to Fix It: CSRF Tokens ๐ก๏ธ
The classic defense is a synchronizer token โ a random, unpredictable secret value that must be included in every state-changing request. The attacker can't read your page's token (cross-origin restrictions block that), so they can't forge it.
Laravel (Built-In, No Excuses)
Laravel ships with CSRF protection enabled by default. You just need to use it:
{{-- In any form โ @csrf adds the hidden token field --}}
<form method="POST" action="/transfer">
@csrf
<input type="number" name="amount" />
<button type="submit">Transfer</button>
</form>
For AJAX requests, grab the token from the meta tag:
// Set once in your layout <head>
// <meta name="csrf-token" content="{{ csrf_token() }}">
// Then in your fetch/axios calls:
fetch("/api/update-profile", {
method: "POST",
headers: {
"X-CSRF-Token": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Alice" }),
});
Laravel's VerifyCsrfToken middleware (included in the default web middleware group) checks every POST/PUT/PATCH/DELETE request automatically. If the token is missing or wrong, the request is rejected with a 419.
The Modern Approach: SameSite Cookies ๐ช
SameSite is the new sheriff in town. It tells the browser not to send cookies with cross-site requests at all:
// In config/session.php (Laravel)
'same_site' => 'lax', // Recommended for most apps
// or
'same_site' => 'strict', // Maximum protection, may break some flows
Here's what each value means:
| Value | Behavior |
|---|---|
Strict |
Cookie never sent on cross-site requests. Most secure, but breaks OAuth redirects and links from emails. |
Lax |
Cookie sent on top-level navigations (clicking a link), but NOT on background requests (img, form, fetch). Sweet spot for most apps. |
None |
Always sent. Requires Secure flag. Only for intentional cross-site use cases. |
Lax blocks the silent form submission attack above โ the auto-submitting form is a cross-site background POST, so the session cookie won't be included.
Use BOTH token validation AND SameSite cookies. Defense in depth.
Testing If You're Vulnerable ๐งช
Quick manual check: build a plain HTML page on a different origin and try to submit a form targeting your app. If your app processes it without a token โ you're exposed.
Better yet, use a proper checklist:
[ ] All state-changing endpoints (POST/PUT/PATCH/DELETE) require CSRF token
[ ] CSRF tokens are validated server-side (not just checked for presence)
[ ] Tokens are unique per session (not hardcoded or reused forever)
[ ] Session cookies have SameSite=Lax or Strict
[ ] CORS policy doesn't allow arbitrary origins for credentialed requests
[ ] APIs used by browsers also validate tokens or use SameSite cookies
The "But I Use an API!" Trap ๐ชค
Developers building JSON APIs often skip CSRF protection because "it's not a form." Big mistake.
If your API uses cookie-based sessions and is consumed by a browser, CSRF applies. The browser doesn't care if you're expecting JSON โ it'll send that cookie regardless.
The safe patterns for APIs:
- Use
Authorization: Bearer <token>headers instead of cookies โ browsers won't auto-attach these cross-site - If you must use cookies, implement CSRF tokens or rely on SameSite=Strict
- Set a strict CORS policy so browsers block cross-origin reads
// API route group in Laravel โ add CSRF to stateful API routes
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
Route::post('/transfer', [TransferController::class, 'store']);
});
Laravel Sanctum handles SPA authentication with CSRF protection built in via the /sanctum/csrf-cookie endpoint. Use it.
The Bottom Line ๐ฏ
CSRF is sneaky because the attack doesn't happen on your site. It happens on some random page your user visited, using their legitimate session like a puppet.
The fix is simple:
- Use CSRF tokens on all state-changing requests (Laravel does this for you)
- Set
SameSite=Laxon session cookies - Don't trust any state-changing GET request โ ever
- CORS policies are your last line for APIs
One hour of work. Eliminates an entire class of account-takeover attacks. That's the best ROI in security.
Stop letting your users be puppets. ๐ช๐ซ
Spotted a CSRF vulnerability in the wild? Want to talk web security? Find me on LinkedIn โ I love a good war story.
More security-focused projects: Check out GitHub for code you can actually trust. ๐
Now go audit your forms. I'll wait. ๐