0x55aa
Back to Blog

CSRF: The Sneaky Attack That Makes Your Users Do Things They Didn't Mean To šŸŽ­šŸ•¹ļø

|
8 min read

CSRF: The Sneaky Attack That Makes Your Users Do Things They Didn't Mean To šŸŽ­šŸ•¹ļø

True story: A colleague of mine built a beautifully designed admin panel. Solid UI, clean API, even had a loading spinner. One problem — no CSRF protection. A red teamer sent him a link that looked like a cat meme. He clicked it. Three seconds later, his own admin account was deleted. The page 404'd. He refreshed. Still 404. He had just fired himself from his own app. šŸ˜‚šŸ’€

Welcome to the wild world of Cross-Site Request Forgery (CSRF) — the attack that doesn't break into your house, it just tricks the person who already has the key to open the door for them.

What Even IS CSRF? šŸ¤”

CSRF is an attack where a malicious website tricks your browser into making an authenticated request to a different site — one where you're already logged in.

Your browser is the puppet. The attacker pulls the strings. Your session cookie does the dirty work.

The core problem in one sentence: Browsers automatically attach cookies to every request for a domain — even requests triggered by OTHER websites.

1. You log into your bank → browser stores session cookie
2. You visit evil.com (cat memes, obviously)
3. evil.com has hidden HTML that fires a request to your bank
4. Your browser sends bank.com the request + cookie šŸŖ
5. Bank says: "Authenticated user? Sure, transferring $1000!"
6. You: "Wait, what just happened?" 😱

No malware. No password theft. Just your own browser being too helpful.

A Live Demo (In a Safe, Educational Way) šŸ”¬

Here's what a classic CSRF attack looks like. Imagine bank.com has a transfer endpoint:

POST /transfer
Host: bank.com
Cookie: session=abc123

amount=1000&to_account=ATTACKER_ACCOUNT

And the attacker builds this page on evil.com:

<!-- evil.com/totally-not-a-hack.html -->
<html>
  <body>
    <h1>You Won a Free iPhone! šŸŽ‰ Click here to claim!</h1>

    <!-- Invisible form that auto-submits on page load -->
    <form 
      id="csrf-form"
      action="https://bank.com/transfer" 
      method="POST"
      style="display:none"
    >
      <input name="amount" value="1000" />
      <input name="to_account" value="ATTACKER_ACCOUNT_9999" />
    </form>

    <script>
      // Fires the moment the page loads šŸ’€
      document.getElementById('csrf-form').submit();
    </script>
  </body>
</html>

That's it. That's the whole attack. No zero-days. No sophisticated exploitation. Just HTML and one submit() call.

You visit the page → form fires → your browser sends your bank cookie along → money gone. All in under a second, while you're still reading "You Won a Free iPhone!"

It gets even sneakier with GET requests:

<!-- If the bank used GET for transfers (please never do this) -->
<img src="https://bank.com/transfer?amount=1000&to=attacker" style="display:none" />

Loading that image fires the request. You never even click anything.

Why Does This Even Work? Blame the Browser 🌐

The root cause is how cookies work. Browsers follow the same-origin policy for reading responses — but they'll happily send cookies with requests to any domain.

evil.com fires POST → bank.com
Browser attaches: Cookie: session=your_real_session

Bank sees: valid session cookie → processes request
Bank does NOT check: "did this request originate from bank.com?"

It's like if your bank processed any wire transfer that arrived in an envelope with your account number on it — without checking who actually sent the envelope. šŸ“¬

Fix #1: CSRF Tokens — The Classic Defense šŸ›”ļø

The most battle-tested fix is the synchronizer token pattern: generate a secret random token, embed it in every form, verify it on the server. An attacker on evil.com can't read the token (same-origin policy blocks cross-origin reads), so they can't forge a valid request.

Generating the token (Node.js / Express example):

const crypto = require('crypto');

// Generate token on session start
function generateCsrfToken(req) {
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString('hex');
  }
  return req.session.csrfToken;
}

// Middleware to inject token into every response
app.use((req, res, next) => {
  res.locals.csrfToken = generateCsrfToken(req);
  next();
});

// In your HTML template (Handlebars / EJS / etc.)
// <input type="hidden" name="_csrf" value="{{csrfToken}}" />

// Middleware to validate token on state-changing requests
function verifyCsrf(req, res, next) {
  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
    const token = req.body._csrf || req.headers['x-csrf-token'];
    
    if (!token || token !== req.session.csrfToken) {
      return res.status(403).json({ error: 'Invalid CSRF token' });
    }
  }
  next();
}

app.post('/transfer', verifyCsrf, (req, res) => {
  // Safe! Only executes if CSRF token matched
  processTransfer(req.body);
});

For SPAs and JSON APIs, pass the token in a header instead of a form field:

// Frontend: read token from cookie or meta tag, send as header
const response = await fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
  },
  body: JSON.stringify({ amount: 100, to: 'friend' })
});

Most frameworks handle this for you — Laravel has @csrf, Rails has protect_from_forgery, Django has {% csrf_token %}. If you're not using the built-in CSRF protection your framework provides, you're doing it wrong. 🚨

Fix #2: SameSite Cookies — The Modern Defense šŸŖ

In 2016, browsers introduced the SameSite cookie attribute. It tells the browser when to attach the cookie to cross-site requests. This is now the first line of defense:

// Express.js — set SameSite on your session cookie
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,    // JS can't read it
    secure: true,      // HTTPS only
    sameSite: 'Strict' // Never sent on cross-site requests
    // Or 'Lax' for more relaxed (still blocks most CSRF)
  }
}));

The three modes explained:

Value Cross-site POSTs Cross-site GETs (top-level nav)
Strict āŒ Blocked āŒ Blocked
Lax (default in modern browsers) āŒ Blocked āœ… Allowed
None āœ… Allowed āœ… Allowed (requires Secure)

Lax is the browser default now and stops the vast majority of CSRF attacks. Strict is even tighter but can break OAuth flows and "open in new tab" UX.

The caveat: SameSite is cookie-level protection. It doesn't help if:

  • You're running on subdomains (same "site" means same registrable domain)
  • You support older browsers (IE11... yes some enterprise apps still do 😢)
  • Cookie is set to SameSite=None

This is why defense in depth matters — use both SameSite cookies and CSRF tokens.

Fix #3: Check the Origin Header šŸ“

For APIs, a quick sanity check: verify the Origin or Referer header matches your domain.

function checkOrigin(req, res, next) {
  const origin = req.headers.origin || req.headers.referer;
  const allowedOrigins = ['https://yourapp.com', 'https://www.yourapp.com'];

  if (req.method !== 'GET' && origin && !allowedOrigins.some(o => origin.startsWith(o))) {
    return res.status(403).json({ error: 'Forbidden: invalid origin' });
  }
  next();
}

This alone isn't bulletproof (headers can sometimes be absent), but it's a cheap extra layer. Think of it as the bouncer checking your ID and your face — belt AND suspenders.

Real-World Gotchas I've Hit šŸŽÆ

Gotcha 1: CSRF on logout

Developers often skip CSRF on logout ("what's the harm?"). But logging someone out on their behalf = denial of service. Protect logout too.

Gotcha 2: JSON doesn't save you

"My API only accepts Content-Type: application/json, so I'm safe from CSRF!"

Nope. Older browsers allowed application/x-www-form-urlencoded content type forgeries that triggered server-side JSON parsing issues. Modern fetch() with custom content types is blocked by CORS preflight — but don't rely on this as your only check.

Gotcha 3: Storing tokens in localStorage

Some devs store JWT auth tokens in localStorage to avoid the cookie problem entirely. That sidesteps CSRF... but opens the door to XSS stealing the token. You've traded one attack surface for another. The recommended approach: httpOnly cookie + CSRF token. Best of both worlds.

The CSRF Checklist āœ…

Before you ship that form or API endpoint:

  • CSRF tokens on all state-changing forms (POST, PUT, PATCH, DELETE)
  • SameSite=Lax or Strict on session cookies
  • HttpOnly and Secure flags on session cookies
  • Origin/Referer validation on sensitive API endpoints
  • CSRF protection on logout endpoints
  • Framework's built-in CSRF protection enabled (not disabled!)
  • Tested with browser dev tools (check cookie flags in Application tab)

The Bottom Line šŸŽÆ

CSRF is one of those attacks that feels silly once you understand it — "the browser just... sends the cookie? Really?" — but it has caused real financial losses, real data breaches, and yes, really deleted admins from their own apps.

The good news: it's one of the most preventable vulnerabilities out there. Use your framework's CSRF protection (seriously, it's already there), set SameSite on your cookies, and sleep soundly knowing your users won't accidentally transfer money to strangers.

The next time someone sends you a link to a "free iPhone" page, you'll know exactly what they're really trying to do. šŸ˜šŸ”’


Enjoyed this? Connect with me on LinkedIn — I share security write-ups, war stories, and occasional cat memes that don't CSRF you.

Want to see secure web app patterns in action? Check my GitHub where every endpoint has a CSRF token and I check the Origin header like a paranoid sysadmin.

P.S. — Go check your session cookie flags right now. I'll wait. Open DevTools → Application → Cookies → is SameSite set? No? Fix it. Yes? Nice work. šŸŽ‰

P.P.S. — If you're still using GET requests for state-changing operations, we need to have a longer conversation. šŸ˜…

Thanks for reading!

Back to all posts