0x55aa
← Back to Blog

CSRF: The Attack Where YOUR Browser Becomes the Weapon 🔫

6 min read

CSRF: The Attack Where YOUR Browser Becomes the Weapon 🔫

Imagine this: You log into your bank, check your balance, then click a totally innocent link your cousin shared on Discord. Twenty seconds later, $2,000 has been transferred to a stranger's account.

You didn't do anything. Your bank's server didn't get hacked. But the money is gone.

That's Cross-Site Request Forgery (CSRF) — and it's been silently wrecking accounts since the early 2000s. OWASP had it in the Top 10 for years for good reason. Let's break down why it's so sneaky and what you can do about it.

How Your Browser Became a Traitor 🕵️

Here's the thing about browsers: they're blindly loyal. If you're logged into bank.com, your browser dutifully sends your authentication cookies with every single request to bank.com — even requests that came from a completely different website.

The attacker doesn't steal your cookie. They don't even need to see it. They just trick your browser into making a request, and your browser helpfully attaches your credentials all by itself.

It's like convincing someone's loyal dog to fetch something — you don't need to own the dog, you just need to throw the ball.

The Attack in Slow Motion 🎬

Let's say bank.com has a transfer endpoint:

POST /transfer
amount=1000&to_account=12345

An attacker creates a malicious webpage that contains this hidden form:

<!-- evil-site.com/free-pizza.html -->
<html>
  <body onload="document.getElementById('csrf-form').submit()">
    <form id="csrf-form"
          action="https://bank.com/transfer"
          method="POST"
          style="display:none">
      <input name="amount" value="2000" />
      <input name="to_account" value="ATTACKER_ACCOUNT" />
    </form>
    <h1>Loading your free pizza... 🍕</h1>
  </body>
</html>

The victim visits evil-site.com/free-pizza.html while logged into their bank. The form auto-submits. The browser fires a POST request to bank.com/transfer and automatically includes the victim's session cookie.

From the bank's server perspective? Totally legitimate request. Same user, same session. Money gone. 💸

No phishing. No malware. Just a browser doing what browsers do.

Why GET Requests Are Extra Dangerous 🚨

If your app uses GET requests for state-changing actions (please tell me you don't), the attacker doesn't even need a form. A single <img> tag does the job:

<!-- This fires the moment the page loads -->
<img src="https://bank.com/transfer?amount=2000&to=attacker"
     width="0" height="0" />

The browser loads the "image." There's no image. But there IS a bank transfer. This is why GET requests must never modify state. POST, PUT, DELETE — those are for mutations. GET is for reading. Engrave it on your desk.

The Fix: CSRF Tokens 🛡️

The classic defense is a synchronizer token pattern. The server generates a unique, unpredictable token per session (or per form), embeds it in the HTML, and requires it back on every state-changing request.

The attacker's evil page can't read your token because of the Same-Origin Policy — it can send requests to other origins, but it can't read their responses. So it can never know your CSRF token.

Here's what it looks like in a Laravel app (which handles this automatically via the VerifyCsrfToken middleware):

// Blade template — Laravel auto-injects the token
<form method="POST" action="/transfer">
    @csrf  {{-- expands to <input type="hidden" name="_token" value="..."> --}}
    <input name="amount" type="number" />
    <input name="to_account" type="text" />
    <button type="submit">Transfer</button>
</form>

For JavaScript/fetch-based requests, send it as a header:

// Grab token from meta tag
const token = document.querySelector('meta[name="csrf-token"]').content;

await fetch('/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token,   // Server validates this
  },
  body: JSON.stringify({ amount: 2000, to_account: '12345' }),
});

The server rejects any request that doesn't include a valid token. Attacker's forged form? No token. Request denied. 🚫

The Modern Defense: SameSite Cookies 🍪

Browsers now support the SameSite cookie attribute, which tells the browser: "Don't send this cookie on cross-site requests."

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
  • SameSite=Strict — Cookie never sent on cross-site requests. Maximum protection, but can cause issues with links from external sites (user gets logged out on click).
  • SameSite=Lax — Cookie sent on top-level navigations (like clicking a link), but NOT on form POSTs or embedded resources. Good balance.
  • SameSite=None — Old behavior. Requires Secure flag. Only use for intentional cross-site flows like OAuth.

Modern browsers default to Lax even when unspecified, which killed off a lot of CSRF attacks quietly in the background. But don't rely on browser defaults — be explicit.

Defense Checklist ✅

  1. Use CSRF tokens on all state-changing forms and AJAX requests.
  2. Set SameSite=Lax or Strict on session cookies.
  3. Validate the Origin / Referer header on the server as a secondary check.
  4. Never use GET for mutations — not /delete?id=42, not /logout, nothing.
  5. Use your framework's built-in CSRF protection — don't roll your own.

Most modern frameworks (Laravel, Django, Rails, Spring, Next.js) include CSRF protection out of the box. The most common mistake is accidentally disabling it for API routes, forgetting that SPAs are also vulnerable.

The Real-World Damage 💥

CSRF has been behind some embarrassing breaches:

  • Netflix (2006) — Attackers could change account email/password via CSRF, locking victims out.
  • YouTube (2008) — Could force-add victims to a channel's friend list at scale.
  • Router attacks — Malicious sites have changed router DNS settings while users browsed at home.

The reason it's dropped off the OWASP Top 10 recently isn't because it's solved — it's because frameworks got better at handling it by default. Roll your own auth? It's probably still wide open.

TL;DR

CSRF weaponizes your own browser against you by forging authenticated requests from a different site. Your server can't tell the difference between you clicking a button and a hacker tricking your browser into clicking it for them.

The fix is two-pronged: CSRF tokens (server validates a secret only the legitimate page knows) and SameSite cookies (browser refuses to send credentials on cross-site requests).

Use both. Every state-changing endpoint. No exceptions.


Found this useful? Drop a follow on GitHub or connect on LinkedIn — I write about security and backend stuff regularly. Got a vulnerability horror story? I'd love to hear it. 👋