Cross-Site Request Forgery (CSRF)
On this page
The Confused Deputy Problem#
Cross-Site Request Forgery (CSRF) makes a logged-in user's browser send a state-changing request the user never intended. It abuses a specific browser behaviour: cookies for a site are attached to every request to that site, no matter which page or tag triggered the request. The server sees a valid session cookie and assumes the user meant to act.
Where XSS abuses the trust a user has in a site, CSRF abuses the trust a site has in the user's browser. It is a "confused deputy" attack: the browser is a deputy holding the user's credentials, and the attacker tricks it into using them.
How It Works#
Suppose you are logged in to bank.example and your session cookie is live. In another tab you open a page an attacker controls. That page contains a form pointed at the bank and a script that submits it automatically:
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="amount" value="10000">
<input type="hidden" name="to" value="attacker-account">
</form>
<script>document.getElementById("f").submit();</script>
When the page loads, the form POSTs to the bank. Because the request goes to bank.example, the browser attaches your bank session cookie automatically. The server sees a valid cookie and processes the transfer. The attacker never saw your cookie or your password; they only needed you to have an active session and to load their page.
Crucially, it does not need a form. Any tag that makes the browser fetch a URL works for GET requests: an <img src="https://bank.example/transfer?amount=10000&to=attacker">, a background CSS url(), or a prefetched link. This is exactly why state-changing actions must never be reachable by GET. A GET that changes data can be triggered by an image tag on any page on the internet.
Why the Attacker Cannot Just Read the Response#
The same-origin policy stops the attacker's JavaScript from reading the bank's responses or the bank's pages. So they cannot steal your balance or copy an anti-CSRF token out of a form. But the same-origin policy does not stop them from sending the request, and the damage (a transfer, a password change, a delete) is done by the request alone. CSRF lives in that gap: send-but-not-read.
Defence 1: SameSite Cookies#
The modern first line of defence is the SameSite cookie attribute, which tells the browser whether to attach a cookie on cross-site requests:
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
SameSite=Strict: the cookie is never sent on any cross-site request. Strong, but it also means a user following a link from another site to yours arrives logged out, which hurts usability.SameSite=Lax: the cookie is withheld on cross-site POST, PUT, DELETE and on subresource loads (images, iframes, fetch), but still sent on top-level GET navigations like clicking a normal link. This is the default most browsers now apply when a cookie has noSameSiteset.SameSite=None: the cookie is sent on all cross-site requests and must be paired withSecure. Only use it when you genuinely need cross-site cookies.
SameSite=Lax as a default blocks the auto-submitting-form attack above, because it is a cross-site POST. It is real protection but not sufficient alone: the default is not guaranteed on older browsers, some flows legitimately need SameSite=None, and Lax still allows cross-site top-level GETs, so a state-changing GET remains exploitable. Treat SameSite as one layer, not the whole answer.
Defence 2: Anti-CSRF Tokens#
The traditional, robust defence is the synchronizer token. The server generates a random, unpredictable token tied to the user's session and embeds it in every form. On submission the server checks that the token in the request matches the one in the session:
import secrets
from flask import session, request, abort
def issue_token() -> str:
token = secrets.token_urlsafe(32)
session["csrf"] = token
return token # embed in a hidden field or a meta tag
def require_token():
sent = request.form.get("csrf") or request.headers.get("X-CSRF-Token")
expected = session.get("csrf")
if not expected or not sent or not secrets.compare_digest(sent, expected):
abort(403)
The attacker's page cannot read the token (same-origin policy), so it cannot include a valid one, and the forged request fails at require_token. Use secrets.compare_digest for a constant-time comparison rather than ==. In real projects you rarely hand-roll this: Django ships {% csrf_token %} and middleware, Flask has Flask-WTF (CSRFProtect), Rails has protect_from_forgery, and Spring Security enables CSRF tokens by default. Let the framework do it and confirm it is turned on.
Defence 3: Double-Submit Cookie (for SPAs and token APIs)#
Single-page apps that talk to a stateless API sometimes cannot store a per-session token server-side conveniently. The double-submit pattern instead sets a random value in a non-HttpOnly cookie; the app's JavaScript reads that cookie and echoes it in a custom header (for example X-CSRF-Token) on every state-changing request. The server checks that header and cookie match. An attacker's page cannot read your cookie from another origin, so it cannot populate the header. Sign or bind the value to the session to prevent a subdomain from injecting its own cookie.
Defence 4: Origin and Content-Type Checks for JSON APIs#
Classic CSRF targets HTML form posts. JSON APIs are exposed too, because a cross-site request can carry Content-Type: text/plain with a JSON-looking body, and some lenient backends parse it anyway. Two checks help. First, verify the Origin header on state-changing requests against an allowlist; browsers send Origin reliably on cross-origin requests, and forging it from another origin is not possible in a browser:
from urllib.parse import urlparse
from flask import request, abort
ALLOWED = {"https://app.example", "https://www.app.example"}
def check_origin():
origin = request.headers.get("Origin")
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
if not origin or urlparse(origin).scheme + "://" + urlparse(origin).netloc not in ALLOWED:
abort(403)
Second, require a real JSON Content-Type and reject text/plain on JSON endpoints, and require a custom header that simple cross-site requests cannot set without triggering a CORS preflight the attacker cannot satisfy.
Which Defence, When#
| Situation | Primary defence |
|---|---|
| Server-rendered forms, session cookies | Framework CSRF token + SameSite=Lax |
| SPA with cookie session | Double-submit token + SameSite + Origin check |
Stateless API with Authorization: Bearer header |
Largely immune (tokens are not auto-attached), still verify Origin |
| Any state-changing endpoint | Never accept GET; require POST/PUT/DELETE |
Note the third row: if your API authenticates purely with a bearer token in a header (not a cookie), CSRF mostly does not apply, because the browser does not attach that header automatically to cross-site requests. CSRF is fundamentally a problem of ambient credentials like cookies.
Checklist#
- No state-changing action is reachable by GET.
- Session cookies carry
SameSite=Lax(orStrict) plusSecureandHttpOnly. - The framework's CSRF protection is enabled and covers every mutating route (check the login and logout routes specifically, which are often exempted by mistake).
- JSON endpoints validate
Originand reject unexpected content types. - Sensitive operations (transfers, email or password changes, deletes) additionally require re-authentication, so even a bypass needs a second factor. Combine this with the session hardening in authentication concepts.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.