0x55aa
← Back to Blog

CSRF: The Attack Your Users Never See Coming 🎭

7 min read

CSRF: The Attack Your Users Never See Coming 🎭

Imagine you're logged into your bank. You click an innocent-looking meme link your friend sent you. Suddenly... you just transferred $5,000 to a stranger. You didn't click any bank button. You didn't confirm anything. Your bank even sent you a confirmation email.

Welcome to Cross-Site Request Forgery (CSRF) — the attack where YOUR browser does the dirty work without you knowing.

This isn't a theoretical vulnerability. It has hit major platforms including YouTube, Gmail, Netflix, and countless banking apps. And the scariest part? It's embarrassingly simple to pull off against unprotected apps.

How CSRF Actually Works (The Evil Tutorial) 🦹

Here's the setup:

  1. You log into yourbank.com. Your browser stores a session cookie.
  2. Without logging out, you visit evil-memes.com.
  3. That page contains hidden HTML — a form that auto-submits to yourbank.com/transfer.
  4. Your browser sees the request to yourbank.com, dutifully attaches your session cookie, and sends it.
  5. The bank sees a valid, authenticated request. Money moves.

You just watched it happen. Let me show you the actual attack code:

<!-- evil-memes.com contains this hidden page -->
<html>
  <body onload="document.forms[0].submit()">
    <form
      action="https://yourbank.com/transfer"
      method="POST"
    >
      <input type="hidden" name="to_account" value="HACKER_ACCOUNT" />
      <input type="hidden" name="amount" value="5000" />
    </form>
    <h1>You Won't Believe This Cat! 🐱</h1>
    <img src="funny-cat.gif" />
  </body>
</html>

That's it. That's the whole attack. The form auto-submits on page load, hidden behind a cat GIF. The victim sees a cat. The hacker sees money.

The key insight: Browsers automatically include cookies when making requests — even cross-site requests. This is the original sin that makes CSRF possible.

Wait, Can't I Just Check the Origin? 🤔

Smart question! Yes, checking Origin or Referer headers helps, but it's not bulletproof:

  • Some browsers don't send Referer (privacy settings, HTTPS→HTTP redirects)
  • Origin can be null in some redirect scenarios
  • It's easy to forget to check on every single endpoint

You need something more reliable. That's where CSRF tokens come in.

The Fix: CSRF Tokens (The Right Way) 🛡️

The solution is elegant: generate a secret, unpredictable token that the attacker can't know. Include it in every state-changing request. Verify it on the server.

The attacker on evil-memes.com can't read your cookies (thanks to the Same-Origin Policy), so they can't steal your CSRF token. Their forged request will be missing it, and your server will reject it.

Here's how it looks in a real app:

# Python/Flask example
import secrets
from flask import Flask, session, request, abort

app = Flask(__name__)

@app.route('/transfer', methods=['GET'])
def transfer_form():
    # Generate a fresh token for this session
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(32)

    return f'''
        <form method="POST" action="/transfer">
            <input type="hidden"
                   name="csrf_token"
                   value="{session['csrf_token']}" />
            <input name="amount" placeholder="Amount" />
            <input name="to_account" placeholder="Account" />
            <button type="submit">Transfer</button>
        </form>
    '''

@app.route('/transfer', methods=['POST'])
def transfer_money():
    # Verify the token before doing ANYTHING
    token = request.form.get('csrf_token')

    if not token or token != session.get('csrf_token'):
        abort(403, "CSRF token invalid. Nice try. 😏")

    # Regenerate token after use (double security)
    session['csrf_token'] = secrets.token_hex(32)

    # Now safe to process the transfer
    process_transfer(request.form['amount'], request.form['to_account'])
    return "Transfer successful!"

The attacker's forged form doesn't know the csrf_token value. Request rejected. 🎉

Modern Apps Use SameSite Cookies (Game Changer) 🍪

If you're building anything in the last few years, there's great news: browsers now support SameSite cookie attributes, which prevent cross-site cookie sending entirely.

// Node.js/Express — setting SameSite cookies
app.use(session({
    secret: process.env.SESSION_SECRET,
    cookie: {
        httpOnly: true,      // Can't be read by JavaScript
        secure: true,        // HTTPS only
        sameSite: 'strict',  // Never sent on cross-site requests
        maxAge: 3600000      // 1 hour
    }
}));

With sameSite: 'strict', when evil-memes.com tries to submit a form to your bank, the browser refuses to send the session cookie. No cookie = not authenticated = request rejected.

The three SameSite modes:

Mode Behavior Use When
strict Cookie never sent cross-site Highest security, may break OAuth flows
lax Sent on top-level navigation (clicking links), not on form POSTs Good default for most apps
none Always sent (requires Secure) Third-party embeds, old behavior

Pro tip: lax is the current browser default for cookies without an explicit SameSite attribute. strict is better if your UX can handle it.

Where Developers Still Get Burned 🔥

Even with CSRF protection in place, these mistakes creep in:

1. Protecting POST but forgetting PUT/DELETE

// You protected POST... but what about this?
app.delete('/users/:id', authenticate, (req, res) => {
    // No CSRF check! DELETE is a state-changing request too!
    User.destroy({ where: { id: req.params.id }});
});

2. Using GET requests for state changes

<!-- This is wrong on so many levels -->
<a href="/admin/delete-user?id=42">Delete User</a>

GET requests are automatically triggered by <img src="">, <iframe src="">, and browser prefetching. Never use GET for actions that change state.

3. Skipping CSRF on API endpoints

// "It's an API, not a form, so CSRF doesn't apply!"
// WRONG — if it uses cookies, CSRF applies.
app.post('/api/change-email', (req, res) => {
    // No CSRF check = vulnerable
});

If your API uses session cookies for authentication, it needs CSRF protection too.

Is Your Framework Protecting You? 🤔

Most modern frameworks include CSRF protection — but you might have turned it off without realizing:

  • Laravel: Built-in VerifyCsrfToken middleware (enabled by default). Don't add routes to the $except list carelessly.
  • Django: {% csrf_token %} in every form. Never use @csrf_exempt unless you truly mean it.
  • Rails: protect_from_forgery is on by default. Don't skip it.
  • Express.js: Use csurf package (now archived, use csrf-csrf instead) or rely on SameSite cookies.
  • Next.js: No built-in CSRF protection for API routes — you must add it yourself or use SameSite cookies.

If you've ever disabled CSRF protection to "fix a bug" or because a tutorial said to, go back and check that now.

Quick Security Checklist ✅

Before you ship:

  • All state-changing endpoints (POST, PUT, PATCH, DELETE) verify CSRF tokens
  • Session cookies have SameSite=strict or SameSite=lax
  • Session cookies have HttpOnly and Secure flags
  • No state-changing GET requests
  • CSRF protection is NOT disabled for any route "temporarily"
  • API endpoints with cookie auth are also protected
  • Tokens are cryptographically random (use secrets.token_hex(), not random())

The Bottom Line

CSRF is one of those attacks that feels stupid simple once you understand it — because it is. Your browser's helpfulness (auto-sending cookies) becomes a weapon in the attacker's hands.

The good news: fixing it is also simple. Use CSRF tokens, set SameSite cookies, and never use GET for state changes. Three rules. That's it.

The bad news: these protections only work if you actually enable them everywhere. One unprotected endpoint is all an attacker needs.


Found a CSRF bug in the wild? Tell me about it on LinkedIn — responsible disclosure stories are my favorite genre of tech horror.

Want more security deep-dives? Check out my posts on XSS, SQL Injection, and Session Hijacking.

P.S. — Go check if your framework's CSRF protection is actually enabled right now. I'll wait. The cat GIF can wait too. 🐱🛡️