07.02 · OWASP A01:2021 (Broken Access Control)

Cross-Site Request Forgery

The browser ships a session cookie with every request to a domain — even when the request was triggered by some other site. CSRF is the family of attacks that exploits exactly that.

The premise

You are logged into your bank in one tab. In another tab, you open a malicious page. That page contains a hidden form that POSTs to bank.example/transfer with attacker-chosen amount and recipient fields. When the page submits, the browser attaches your bank cookie to the request — because the request is going to bank.example, and that's where your cookie lives. The bank sees an authenticated transfer. You see nothing.

The bug is not in the bank's authentication. The bug is that the bank can't tell the difference between a request its own page made and a request another page made on the user's behalf.

Try it — bank.example next to attacker.example

Two browser tabs side by side. You're logged into the bank. The other tab is a blog you stumbled onto. Click around — watch the network panel underneath.

CSRF DEMO · TWO TABS, ONE BROWSER
Cookie SameSite:
CSRF Token:
First Federal
https://bank.example/dashboard
Logged in as alice@example.com session active
Checking balance$5,000.00
Initial deposit+ $5,000.00
Win a free iPhone!
https://totally-legit-blog.example/win
Top 10 productivity hacks (You won't believe #7)
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua...
SPONSORED: Click for a free iPhone —
<!-- hidden form on this attacker page --> <form action="https://bank.example/transfer" method="POST"> <input name="recipient" value="attacker@bad.example"> <input name="amount" value="4950.00"> </form> <script>document.forms[0].submit()</script>
Network — bank.example
No requests yet.
Try sending a legitimate transfer first, then click CLAIM PRIZE in the attacker tab.

Why this used to work everywhere

CSRF dominated the OWASP top 10 from 2007 to 2017. Two architectural choices made the web wide open by default:

  • Cookies are ambient. Once a browser holds a cookie for a domain, every request to that domain carries it — regardless of which page caused the request. The browser does not ask the receiving server “was this request something the user explicitly chose to make?”
  • Forms can cross origins. An HTML form on any page can POST to any URL. Same-origin policy stops scripts from reading the response, but the request itself goes through and the side effects (transfer money, change email, delete a row) happen anyway.

The defensive moves that closed most of this gap arrived gradually: the CSRF token pattern in the late 2000s, then the SameSite cookie attribute defaulted to Lax by Chrome in 2020. Most production frameworks now ship CSRF protection on by default. CSRF still happens; it just isn't free anymore.

The two defenses, and why both matter

1. CSRF tokens (synchronizer pattern)

The server embeds a random secret in every form it renders. When the form submits, the server checks the token before performing the action. An attacker's cross-origin form does not have the secret — their page can't read the legitimate page's HTML, so it can't know the value to submit.

app.post('/transfer', requireAuth, (req, res) => { if (req.body.csrf_token !== req.session.csrf_token) { return res.status(403).send('CSRF check failed'); // attacker request rejected } transfer(req.body.recipient, req.body.amount); res.send('OK'); });

2. SameSite cookies

The cookie itself carries a flag telling the browser when it may travel.

ValueBehaviorUse case
SameSite=StrictCookie sent only on requests where the URL bar matches the cookie's domain. Even clicking a link from another site won't include it.Bank dashboards, settings pages, anywhere first-click context is fine.
SameSite=LaxCookie sent on top-level GET navigations from other sites, but never on cross-site POSTs or iframe loads. Browser default since 2020.Most session cookies. Balances usability against CSRF protection.
SameSite=NoneCookie sent on every cross-site request. Requires Secure. Pre-2020 default behavior.Embedded third-party widgets that legitimately need cross-site cookies. Rare.

SameSite=Lax alone defeats the classic CSRF pattern (attacker page auto-submits a cross-site POST). CSRF tokens still matter for any legitimate cross-origin flow, for older browsers, and as defense in depth.

Beyond the form post

  • Login CSRF. Attacker submits the victim's browser into the attacker's account on a target site. Subsequent activity (uploaded photos, search history, saved payment methods) accrues to an account the attacker controls. Used against Google, Yahoo, and YouTube historically.
  • Stored CSRF / CSWSH. Cross-site WebSocket hijacking: attacker page opens a WebSocket to a target the user is logged into, then reads messages over the long-lived connection. SameSite only recently extended to cover the upgrade request.
  • GET-side-effect bugs. Any endpoint that mutates state on GET (e.g. /logout, /delete?id=42) is CSRF-able via an <img src> tag from any site on the web. Don't put side effects on GET. RFC 7231 has been telling you for a decade.
  • JSON CSRF. Old browsers and misconfigured CORS allow an attacker to send a cross-site POST with a JSON body to an API. Newer browsers enforce a CORS preflight, which kills this; legacy systems are still vulnerable.

Notable incidents

  • Netflix CSRF — 2006. Attacker could change account email, password, billing address via a single image tag. Netflix patched after disclosure.
  • ING Direct — 2008. CSRF in transfer flow allowed attacker-controlled money movement. Disclosed in the foundational Bilal et al. paper that named the bug class.
  • Twitter — 2010. CSRF could post tweets and follow accounts on a logged-in user's behalf. Patched same day.
  • Endless small things — every framework's CSRF advisories archive. Django, Rails, Laravel, Spring — all have shipped patches for token-bypass bugs over the years. The bug class doesn't die; it just hides behind framework versions.