Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Register Flask-WTF's CSRFProtect(app). It requires the session's token on every POST, PUT, PATCH and DELETE: render {{ csrf_token() }} in a hidden csrf_token field, or send it from scripts in an X-CSRFToken header. Over HTTPS it also requires a same-origin Referer. Change state only in POST views, and set SESSION_COOKIE_SAMESITE = "Lax".

On this page

Steps

  1. Install Flask-WTF and register CSRFProtect(app) where the app is created; it protects every view, not only those that use FlaskForm.
  2. Put <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> in every form that posts, and {{ csrf_meta_tag() }} (Flask-WTF 1.3) in the page head for scripts, which send the value in an X-CSRFToken header.
  3. Declare state-changing views with @app.post (or methods=["POST"]) and read request.form, never request.values or request.args.
  4. Set SESSION_COOKIE_SAMESITE = "Lax", SESSION_COOKIE_SECURE = True and SESSION_COOKIE_HTTPONLY = True; Flask sets no SameSite attribute by default.
  5. Call session.clear() before storing the user at sign-in, so nothing from the anonymous session, the CSRF token included, carries over.
  6. Optionally register @app.errorhandler(CSRFError) to show a friendly page; keep the response a 400 or 403.
  7. Add regression tests that send the victim's cookie with the headers a browser sends for a cross-site form, a sibling subdomain, another user's token and a cross-site link.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def email_routes(app: Flask, accounts: dict[str, dict]) -> None:
    # GET as well as POST: the link in the "confirm your new address" email uses GET.
    @app.route("/account/email", methods=["GET", "POST"])
    def change_email():
        account = accounts.get(session.get("user", ""))
        if account is None:
            return "Sign in first", 401
        email = request.values.get("email", "")  # query string or form body
        if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
            return "Enter a valid email address", 400
        account["email"] = email
        return redirect("/account", 303)

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix

Fixedimpl/fixed.pyPython
def protect(app: Flask, trusted_origin: str) -> None:
    # Every POST, PUT, PATCH and DELETE must carry the token stored in the session, in
    # the csrf_token form field or an X-CSRFToken or X-CSRF-Token header. Over HTTPS,
    # Flask-WTF also requires a same-origin Referer (WTF_CSRF_SSL_STRICT, on by default).
    CSRFProtect(app)

With IMPL=fixed, 12 of 12 tests pass.

The fix: change state only on POST

Fixedimpl/fixed.pyPython
def email_routes(app: Flask, accounts: dict[str, dict]) -> None:
    @app.post("/account/email")  # POST only, so a link cannot reach it
    def change_email():
        account = accounts.get(session.get("user", ""))
        if account is None:
            return "Sign in first", 401
        email = request.form.get("email", "")
        if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
            return "Enter a valid email address", 400
        account["email"] = email
        return redirect("/account", 303)

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}


def protect(app: Flask, trusted_origin: str) -> None:
    host = urlsplit(trusted_origin).netloc  # "portal.example.test"

    @app.before_request
    def check_referer():
        referer = request.headers.get("Referer")
        # Some browsers and privacy tools strip the Referer, so allow requests without one.
        if request.method in UNSAFE_METHODS and referer and host not in referer:
            return "Cross-site request refused", 403
        return None

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_csrf.pyPython
# Exploit: each forged request carries Alice's session cookie. Browsers withhold a
# SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
# GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.

def forge(browser, headers, fields=None):
    return browser.post("/account/email", headers, {**(fields or {}), "email": "mallory@evil.example"})


def test_exploit_cross_site_form_post_without_a_token_is_refused(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(EVIL)))


def test_exploit_foreign_origin_is_refused_when_the_browser_sends_no_fetch_metadata(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, {"Origin": EVIL, "Referer": f"{EVIL}/"}))


def test_exploit_sibling_subdomain_is_refused_although_samesite_cookies_reach_it(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(SIBLING, "same-site")))


def test_exploit_token_from_the_attackers_own_session_is_refused(sign_in, accounts):
    bobs_fields = hidden_fields(sign_in("bob").account_page())
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(EVIL), bobs_fields))


def test_exploit_referer_that_merely_contains_the_host_name_is_refused(sign_in, accounts):
    alice = sign_in("alice")
    assert_refused(accounts, forge(alice, form_from(LOOKALIKE)))


def test_exploit_page_that_suppresses_the_referer_is_refused(sign_in, accounts):
    # <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
    alice = sign_in("alice")
    headers = {"Origin": "null", "Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document"}
    assert_refused(accounts, forge(alice, headers))


def test_exploit_cross_site_get_navigation_cannot_change_the_email(sign_in, accounts):
    # Lax cookies are sent on top-level GET navigations, so only the method protects this.
    alice = sign_in("alice")
    res = alice.get("/account/email?email=mallory%40evil.example", link_from(EVIL))
    assert accounts["alice"]["email"] == "alice@example.test", f"a GET changed Alice's email (HTTP {res.status_code})"
    assert res.status_code >= 400

Pitfalls

  • Flask has no CSRF protection of its own. A FlaskForm checks its own token on validate_on_submit(), but views that read request.form directly are unprotected until CSRFProtect is registered.
  • @app.route(..., methods=["GET", "POST"]) with request.values reads the query string too, so a link can change state, and CSRFProtect never looks at GET (WTF_CSRF_METHODS defaults to POST, PUT, PATCH and DELETE).
  • @csrf.exempt, or WTF_CSRF_ENABLED = False copied from test settings into production, switches the check off. Semgrep's flask-wtf-csrf-disabled rule only catches the second.
  • Over HTTPS, WTF_CSRF_SSL_STRICT (on by default) also requires a Referer from the same origin, so Referrer-Policy: no-referrer on your own pages breaks every form. Behind a TLS-terminating proxy, use Werkzeug's ProxyFix so request.is_secure is true; otherwise the Referer check is skipped.
  • Tokens expire after WTF_CSRF_TIME_LIMIT, 3600 seconds by default: a form left open for an hour fails with 'The CSRF token has expired.' Set it to None to make the token last as long as the session.
  • The raw token lives in the session, which by default is Flask's signed session cookie, so the protection is only as good as SECRET_KEY (and WTF_CSRF_SECRET_KEY, which signs the form token if set). Keep the keys secret and the same on every worker.
  • A CSRF failure raises CSRFError, a BadRequest, so clients see 400 rather than 403 unless you register an error handler.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/python-flask does this for you).
  2. Against your own app, replay a forged post with a signed-in user's session cookie and no token, for example curl -i -b 'session=...' -H 'Origin: https://evil.example' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 400 'The CSRF token is missing.' with no change; repeat as a GET.
  3. Grep for csrf.exempt, WTF_CSRF_ENABLED and methods=["GET", "POST"]: scanners do not report a Flask app that never registers CSRFProtect.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSRF in Python and Flask
ToolRuleWhat it found here
Semgreppython.flask.security.audit.wtf-csrf-disabled.flask-wtf-csrf-disabledNo findings on this card. The rule flags an explicit WTF_CSRF_ENABLED = False; an app that never registers CSRFProtect, like impl/vulnerable.py and impl/wrong.py, is not reported.Checked: Semgrep 1.180.0 (CE)
CodeQLpy/csrf-protection-disabledNot expected to report anything on this card: the only CSRF settings the Python library models are Django's MIDDLEWARE list and its csrf decorators (semmle/python/frameworks/Django.qll); Flask and Flask-WTF are not modelled.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
flask-wtf1.3.0
wtforms3.2.2
itsdangerous2.2.0
werkzeug3.1.9
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/python-flaskfolder.

Shell
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csrf/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • flaskWtf 1.3.0
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
Dependency freshness
requirements.txt pins all 14 packages with sha256 hashes for every published file. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m; MarkupSafe 3.0.3 and iniconfig 2.3.0 are used instead of 3.0.4 and 2.3.1, which were under 14 days old and fix no security issue. OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Flask-WTF: CSRF protection (CSRFProtect, csrf_token, csrf_meta_tag, CSRFError)
  3. Flask-WTF: configuration (WTF_CSRF_METHODS, WTF_CSRF_TIME_LIMIT, WTF_CSRF_SSL_STRICT)
  4. Flask-WTF 1.3.0 source: flask_wtf/csrf.py
  5. Flask: configuration handling (SESSION_COOKIE_SAMESITE and related settings)
  6. MDN: Set-Cookie (SameSite)
  7. MDN: Referrer-Policy (effect on the Origin header)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.