WebPublished 6/13/2025

CSRF: How Cross-Site Request Forgery Actually Works

An explanation of why browsers attach your login credentials to requests without you knowing, how CSRF attacks actually work, and how to put defenses like CSRF tokens and SameSite cookies into practice

Picture this: you log into your bank's website in the morning to handle a wire transfer, forget to log out, then go browse a forum and click what looks like a harmless link. A few minutes later, money has vanished from your account — and you never typed a password anywhere. That's CSRF (Cross-Site Request Forgery) in its classic form. Let's dig into how this attack actually works, and what actually stops it.

First, a Browser Habit Gets Exploited

To understand CSRF, you first need to know about a basic browser behavior: once you log into a domain, the browser stores the cookie it gets back. From then on, any page that fires a request at that domain gets the cookie attached automatically — the browser doesn't care whether you triggered the request on purpose or some malicious site fired it behind your back.

This behavior exists for a good reason — you shouldn't have to log in again every time you send a request. But that's exactly where the problem lives: the browser only checks whether a request is headed to a domain you're logged into, not whether you actually meant to send it. CSRF attacks exploit that gap, making requests under your identity without you ever knowing.

A Concrete Attack Example

Say a bank's website has a transfer API that's a little too casually designed — a plain GET request triggers it:

GET https://bank.com/transfer?to=attacker&amount=50000

As long as you've logged into bank.com, your browser is holding onto a session cookie for it. All an attacker has to do is lure you to a malicious page hiding something like this:

html
<img src="https://bank.com/transfer?to=attacker&amount=50000" style="display:none" />

When the browser loads that image, it fires off a perfectly normal request to bank.com — cookie included. The server sees a valid session cookie and happily processes the transfer, with no idea the request wasn't something you actually clicked on the bank's site.

If the API only accepted POST, that wouldn't stop much either — the attacker just switches tactics:

html
<form id="csrf-form" action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="50000" />
</form>
<script>
  document.getElementById('csrf-form').submit() // fires automatically as soon as the page loads
</script>

The moment you open that page, the form submits itself, and the bank's server receives a transfer request with a perfectly valid cookie attached. You might not even get a good look at the page before it's done.

Doesn't CORS Block This Kind of Cross-Origin Request?

This is the most common misconception. You might think: doesn't the browser's Same-Origin Policy block cross-origin requests? The CORS post covered how cross-origin responses get blocked from being read. But here's the key detail: a CSRF attacker doesn't need to read the response at all.

The Same-Origin Policy blocks JavaScript from reading a cross-origin response — but the request itself still reaches the server, and the server still processes it. In the transfer example above, the attacker doesn't care what the bank sends back; the attack already succeeded the moment the money moved. That's exactly why CORS is not a CSRF defense — the two guard against completely different problems. CORS protects data confidentiality (who gets to read the response), while CSRF defenses protect the authenticity of an action (whether the user actually meant to send this request).

Defense One: CSRF Tokens

The classic approach is called the synchronizer token pattern. When the server renders a form page, it generates a random, unpredictable token and embeds it in the form:

html
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="a8f5f167f44f4964e6c998dee827110c" />
  <input type="text" name="amount" />
</form>

On submission, the server checks that this token matches the one it originally issued, and only proceeds if it does. An attacker can trick your browser into firing a request, but they can't guess that random token — so the forged request gets rejected.

In Express, paired with a package like csrf-csrf, it looks roughly like this:

js
import { doubleCsrf } from 'csrf-csrf'
 
const { generateToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: 'csrf-token',
})
 
app.get('/transfer-form', (req, res) => {
  const token = generateToken(req, res) // generate a token for the frontend
  res.render('transfer', { token })
})
 
app.post('/transfer', doubleCsrfProtection, (req, res) => {
  // only reached once validation passes
  res.send('Transfer successful')
})

Defense Two: SameSite Cookies

CSRF tokens work, but wiring them into every form and every API by hand adds up. A more popular approach in recent years attacks the problem at the cookie level, using the SameSite attribute to restrict when a cookie gets attached at all:

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly

SameSite takes three values:

  • Strict: the cookie is only sent on requests that originate from the same site — even a link you click from an external site won't carry it. This is the most restrictive option, though it can break some legitimate cross-site navigation flows.
  • Lax: the default in most browsers. Same-site requests carry the cookie as normal, and top-level cross-site navigations (like clicking a link that takes you to another site) still carry it too — but cross-site form submissions and fetch calls don't. The auto-submitting form attack shown earlier fails under Lax, because that POST request counts as cross-site and the cookie never gets attached.
  • None: no restriction at all — the cookie is sent in every context, but it must be paired with Secure. This is really only for cases where you genuinely need cross-site cookies, like a third-party embed.

Setting your session cookie to SameSite=Lax or Strict cuts off the automatic-cookie-attachment behavior that CSRF attacks depend on, right at the source — no need to bolt token validation onto every API.

Defense Three: Checking the Origin or Referer Header

Another supplementary measure is checking the request's Origin or Referer header on the server, to confirm the request actually came from your own domain:

js
app.post('/transfer', (req, res, next) => {
  const origin = req.get('Origin') || req.get('Referer')
 
  if (!origin || !origin.startsWith('https://bank.com')) {
    return res.status(403).send('Request rejected')
  }
 
  next()
})

This approach is simple and easy to reason about, but it shouldn't be your only line of defense. The Referer header can get stripped by a user's browser settings or a privacy extension in some situations, so being too strict about it risks blocking legitimate users. It works better as a second layer on top of CSRF tokens or SameSite cookies, not a replacement for them.

Sensitive Actions Deserve One More Check

Beyond these technical defenses, high-risk actions — transfers, password changes, account deletion — deserve one more layer even if everything above is already in place: require the user to re-enter their password, or confirm via SMS or email. This isn't specifically a CSRF defense; it's defense in depth. Even if something upstream has a hole in it someday, an attacker still can't sail all the way through to the final step.

A Checklist for Putting This into Practice

When designing an API that mutates data, work through this list:

  1. Never use GET for anything that changes state — it's too easy to trigger from tags like <img> or <script>.
  2. Set your session cookie to at least SameSite=Lax, and use Strict wherever you can.
  3. For forms touching money or account security, add CSRF token validation on top — don't rely on cookie settings alone.
  4. Check Origin or Referer on the server as a last line of defense.
  5. Require re-authentication for high-risk actions, rather than trusting a single login session to carry all the way through.

Wrapping Up

The core problem with CSRF is that the browser automatically attaches your login credentials, with no way to tell whether a request came from you or was triggered behind your back. The attacker never needs to read any response — getting your browser to fire off one request with a valid cookie attached is already enough, which is exactly why this is a different problem from what CORS protects against. Real protection comes from combining CSRF tokens, SameSite cookies, and origin checks — not picking just one and calling it done. The next time you design an API that mutates data, run through all of these layers.