Cross-Site Request Forgery (CSRF, CWE-352): How It Works and How to Prevent It
A page on another site can make a logged-in user's browser send a state-changing request to your app, and the browser attaches the session cookie automatically, so a route that only checks the cookie performs the action. Synchronizer tokens are the primary defense; SameSite cookies and Sec-Fetch-Site checks add defense in depth, with a Flask and Flask-WTF example.
Plain English Explainer (ELI5)
Your office has a mail room that carries out any signed request slip left in the 'out' tray, and it trusts slips because they sit in your tray, which only you can reach. A visitor asks you to drop a sealed envelope in your tray on the way out. Inside is a slip saying 'change the delivery address for all my parcels'. The mail room sees it in your tray, assumes you wrote it and carries it out. CSRF works the same way: the browser puts your session cookie on every request to your bank, including one that a different website started. The fix is a secret stamp that only pages from your own office can put on a slip, so the mail room rejects slips without it.
Core Concepts & Subsystem Terms
- Ambient credentials
- Browsers attach cookies and HTTP authentication to requests for a site no matter which page started them. A request from
evil.exampletoapp.examplestill carries theapp.examplesession cookie unless the cookie'sSameSiteattribute blocks it. - SameSite cookie attribute
Strictwithholds the cookie on every cross-site request,Laxsends it only on top-level GET navigations, andNonealways sends it (and requiresSecure). Chromium treats cookies without the attribute asLax; Firefox and Safari do not, and 'same-site' still includes sibling subdomains.- Synchronizer token
- A random value tied to the session that the server puts in each form or page and requires on every unsafe request. Another site cannot read it, so it cannot forge a valid request. Flask-WTF's
CSRFProtectissues it throughcsrf_token()and checks thecsrf_tokenfield orX-CSRFTokenheader. - Fetch Metadata (Sec-Fetch-Site)
- When present, this browser-controlled request header reports whether the request is same-origin, same-site, cross-site, or user-initiated (
none). It may be absent; keep CSRF tokens as the primary defense. - State change on GET
- A route that changes data on GET can be triggered by an
<img>tag or a link, andSameSite=Laxstill sends the cookie on top-level GET navigations. Readingrequest.valuesaccepts the query string as well as the form body.
Step-by-Step Attack Flow
The victim is signed in
They have a session cookie for app.example, sent with SameSite=None or with no attribute in a browser that does not default to Lax.
They open a page the attacker controls
It holds a hidden form that posts to https://app.example/account/email and submits itself on load. A form that posts the benign test value email=test@example.com illustrates the case.
The browser sends the request with the cookie
The request carries the victim's session, and the server sees nothing that tells it the form lived on another site.
The server changes the account
The email address becomes the attacker's, who then resets the password and takes over the account. In 2008 Zeller and Felten showed CSRF flaws of this kind at ING Direct, YouTube and The New York Times.
Source Code: Flaw vs. Secure Implementation
# app.py: the session cookie is the only proof that the user meant it
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET works too, and request.values also reads the query string
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
# app.py: token, SameSite cookie and Fetch Metadata together
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# every POST needs csrf_token; forms render {{ csrf_token() }}, fetch() sends X-CSRFToken
csrf = CSRFProtect(app)
# Reject reported untrusted sites; CSRFProtect handles requests without this header.
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
site = request.headers.get("Sec-Fetch-Site")
# Missing Fetch Metadata is not proof of same-origin; CSRFProtect remains primary.
if site is not None and site not in ("same-origin", "none"):
abort(403)
# POST only, data from the form body
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
Engineering & System Hardening Checklist
- Change state only on POST, PUT, PATCH or DELETE, and read form data from the body (
request.form), never fromrequest.valuesor the query string. - Enable the framework's CSRF middleware for the whole app (
CSRFProtect, DjangoCsrfViewMiddleware, Spring Securitycsrf()) and include the token in every form and AJAX request. - Set session cookies to
Secure,HttpOnlyandSameSite=LaxorStrict; useSameSite=Noneonly for cookies that must work inside cross-site iframes. - Reject unsafe requests whose
Sec-Fetch-Siteheader iscross-siteorsame-site. - Add a CI test that sends each state-changing route a request without the token and expects 400 or 403.
- Keep CSRF token validation enabled when Sec-Fetch-Site is absent; the header check is defense in depth.