Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 2 of 6

Also part of: Ethical Hacking (methodology, lab-only)

CSRF: Synchronizer Tokens and SameSite Cookies, Fixed and Tested

A logged-in user's cookie lets a forged POST move money. Add a per-session CSRF token, set SameSite=Lax, and pin both with a failing-then-passing pytest.

By SkillByExample · Published

web-security owasp csrf samesite cookies flask pytest

A browser attaches your app’s session cookie to every request for your app, whichever site started it. That’s cross-site request forgery (CSRF): another page submits a form to your /transfer endpoint, the victim’s cookie rides along, and your server can’t tell it from a real click. This lesson reproduces that against a small local Flask app, fixes it with a synchronizer token and a SameSite cookie, and pins the fix with tests. To learn the attack side, use PortSwigger’s CSRF labs.

The environment is the one from the first lesson: Python 3.12.10, Flask 3.1.3 (Werkzeug 3.1.9) and pytest 9.1.1 in a virtual environment.

# app.py
from flask import Flask, abort, request, session

app = Flask(__name__)
app.secret_key = "dev-only-not-a-secret"
BALANCES = {"alice": 100, "mallory": 0}


@app.post("/login")
def login():
    session["user"] = request.form["user"]  # demo login: no password
    return "logged in\n"


@app.post("/transfer")
def transfer():
    user = session.get("user") or abort(401)
    to, amount = request.form["to"], int(request.form["amount"])
    BALANCES[user] -= amount
    BALANCES[to] += amount
    return f"{user} sent {amount} to {to}; balances {BALANCES}\n"

Run it with flask --app app run --port 5001. Log in as Alice with curl, saving the cookie to a jar the way a browser would, then send the request a hostile page would make the browser send. It carries Alice’s cookie and another site’s Origin, and the form fields the attacker chose:

$ curl -s -i -c jar.txt -d user=alice http://127.0.0.1:5001/login
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Date: Wed, 14 Oct 2026 13:54:47 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 10
Vary: Cookie
Set-Cookie: session=eyJ1c2VyIjoiYWxpY2UifQ.as-Jpw.4h106trtF9ivqUb40qIWrMN9J0A; HttpOnly; Path=/
Connection: close

logged in
$ curl -s -b jar.txt -H "Origin: http://attacker.localhost" -d to=mallory -d amount=50 http://127.0.0.1:5001/transfer
alice sent 50 to mallory; balances {'alice': 50, 'mallory': 50}

The transfer went through. The server checked the one thing a forged request has, a valid session cookie, and nothing a forged request lacks. Look at the Set-Cookie line too: HttpOnly stops JavaScript reading the cookie, but there’s no SameSite attribute. Chrome and Edge treat such a cookie as Lax, but Firefox and Safari send it on cross-site requests, so never rely on the default: set the attribute explicitly.

A test that proves it

The test logs in, sends the forged form, and expects a 403 with balances unchanged.

# test_csrf.py
from app import app, BALANCES

FORGED = {"to": "mallory", "amount": "50"}


def logged_in_client():
    client = app.test_client()
    client.post("/login", data={"user": "alice"})
    return client


def test_forged_post_is_rejected():
    before = dict(BALANCES)
    r = logged_in_client().post("/transfer", data=FORGED,
                                headers={"Origin": "http://attacker.localhost"})
    assert r.status_code == 403
    assert BALANCES == before
$ pytest -q --tb=short
F                                                                        [100%]
================================== FAILURES ===================================
________________________ test_forged_post_is_rejected _________________________
test_csrf.py:17: in test_forged_post_is_rejected
    assert r.status_code == 403
E   assert 200 == 403
E    +  where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
=========================== short test summary info ===========================
FAILED test_csrf.py::test_forged_post_is_rejected - assert 200 == 403
1 failed in 0.18s

The test client keeps cookies between calls just like a browser, so this is the same request curl sent. It’s a regression test for a request your app must refuse.

The fix: a token only your own pages know

The server puts a random token in the session and in a hidden field of its own form, and rejects any POST whose form token doesn’t match. Add import hmac and import secrets at the top of app.py, then add:

@app.get("/transfer")
def transfer_form():
    session.get("user") or abort(401)
    token = session.setdefault("csrf", secrets.token_urlsafe(32))
    return ('<form method="post">'
            f'<input type="hidden" name="csrf_token" value="{token}">'
            '<input name="to"> <input name="amount"> <button>Send</button></form>\n')


@app.before_request
def check_csrf_token():
    if request.method == "POST" and request.endpoint != "login":
        expected = session.get("csrf", "")
        sent = request.form.get("csrf_token", "")
        if not expected or not hmac.compare_digest(expected, sent):
            abort(403)

Rejecting is only half the test. Append one that proves the real form still works, by reading the token out of the page the way a browser would:

def test_real_form_post_works():
    client = logged_in_client()
    form = client.get("/transfer").get_data(as_text=True)
    token = form.split('name="csrf_token" value="')[1].split('"')[0]
    r = client.post("/transfer", data={"to": "mallory", "amount": "5", "csrf_token": token})
    assert r.status_code == 200
$ pytest -q --tb=short
..                                                                       [100%]
2 passed in 0.11s

Restart the server and replay the forged request:

$ curl -s -c jar.txt -d user=alice http://127.0.0.1:5001/login
logged in
$ curl -s -o /dev/null -w "%{http_code}\n" -b jar.txt -H "Origin: http://attacker.localhost" -d to=mallory -d amount=50 http://127.0.0.1:5001/transfer
403

The cookie is still valid and still sent, but the request is refused because it has no token. The attacker’s page can make the browser send requests to your app, but the same-origin policy stops it reading the responses, so it never sees the hidden field. The check runs in before_request, so a POST route added later is covered without anyone remembering to protect it. /login is exempt here because there’s no session yet. Real apps protect login too (login CSRF signs the victim into the attacker’s account), usually with a token tied to a pre-login cookie. Flask-WTF’s CSRFProtect packages this pattern.

Defence in depth: SameSite=Lax

Tell the browser not to send the session cookie on cross-site POSTs at all. Add one line after app.secret_key:

app.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_HTTPONLY=True)
$ curl -s -i -d user=alice http://127.0.0.1:5001/login | grep -i set-cookie
Set-Cookie: session=eyJ1c2VyIjoiYWxpY2UifQ.as-J6w.d4buUZxkiRJ8VLJlrbQ548gSvrM; HttpOnly; Path=/; SameSite=Lax

The cookie now carries SameSite=Lax, and both tests still pass. curl ignores the attribute, so it can’t demonstrate the effect. A browser honours it, and stops attaching this cookie to a POST started by another site. A site is the scheme plus the registrable domain, so attacker.example.com is a different site from bank.example, but evil.bank.example is the same site. Lax still sends the cookie on top-level GET navigations, which is one more reason a GET must never change state. Keep the token: it covers older browsers and same-site subdomains. It does nothing for a GET, because check_csrf_token only runs on POST, so the fix for a state-changing GET is to make it a POST.

Practice

  1. Add an Origin check to check_csrf_token: if a POST has an Origin header that isn’t this app’s own, return 403 even when the token is valid. Write the test first.
Solution
def test_valid_token_from_foreign_origin_is_rejected():
    client = logged_in_client()
    form = client.get("/transfer").get_data(as_text=True)
    token = form.split('name="csrf_token" value="')[1].split('"')[0]
    r = client.post("/transfer", headers={"Origin": "http://attacker.localhost"},
                    data={"to": "mallory", "amount": "5", "csrf_token": token})
    assert r.status_code == 403

It fails with assert 200 == 403 until you add, at the top of the if block:

        origin = request.headers.get("Origin")
        if origin is not None and origin + "/" != request.host_url:
            abort(403)

request.host_url is the app’s own scheme and host with a trailing slash. A missing Origin is let through to the token check, because some clients and older browsers omit the header.

  1. Prove the token is bound to the session: log in as Mallory in a second test client and submit a transfer using a token taken from Alice’s form.
Solution
def test_alices_token_is_useless_in_mallorys_session():
    alice = logged_in_client()
    form = alice.get("/transfer").get_data(as_text=True)
    alice_token = form.split('name="csrf_token" value="')[1].split('"')[0]
    mallory = app.test_client()
    mallory.post("/login", data={"user": "mallory"})
    r = mallory.post("/transfer", data={"to": "alice", "amount": "0", "csrf_token": alice_token})
    assert r.status_code == 403

It passes: Mallory’s session has no csrf value yet, and even after she loads her own form, her token is a different random string. A token that isn’t tied to a session can be replayed from the attacker’s own account.

  1. Change the cookie to SameSite=Strict. What does the Set-Cookie header show, and what would a user notice?
Solution

Set SESSION_COOKIE_SAMESITE="Strict". The header becomes:

Set-Cookie: session=...; HttpOnly; Path=/; SameSite=Strict

With Strict, the browser doesn’t send the cookie even when the user clicks a link to your app from another site, such as an email or a search result. The user arrives looking logged out until the next same-site navigation. Lax is the usual compromise for a session cookie.

Frequently Asked Questions

What is a CSRF attack?
Cross-site request forgery is when another website makes the victim's browser send a request to your app, for example by auto-submitting a hidden form. The browser attaches the victim's session cookie, so your server sees an authenticated request it can't tell apart from a real one. Any state-changing endpoint that relies only on a cookie for authentication is exposed.
How does a CSRF token prevent CSRF?
The server stores a random token in the user's session and puts the same value in a hidden field of its own forms. On every state-changing request it rejects the request unless the submitted token matches the session's. Another site can make the browser send the cookie, but it can't read your pages, so it never learns the token.
Is SameSite=Lax enough to stop CSRF?
It stops most of it: a cookie marked SameSite=Lax isn't sent on cross-site POST requests, form submissions included. It still goes out on top-level cross-site GET navigations, so a GET that changes state stays exploitable. Requests from a sibling subdomain also count as same-site. Treat SameSite as defence in depth alongside a CSRF token, not as a replacement.
Do JSON APIs need CSRF protection?
Not if the API authenticates with a header such as Authorization: Bearer, because browsers never add that header to a cross-site request on their own. They do need it if they authenticate with cookies. A plain HTML form can't send Content-Type: application/json, but relying on that detail is fragile, so use a token or a strict Origin check for cookie-authenticated APIs.
Why compare CSRF tokens with hmac.compare_digest?
A normal string comparison stops at the first differing character, so its timing can leak how many leading characters were right. hmac.compare_digest takes the same time however much of the input matches. It's cheap, so use it for any secret comparison, CSRF tokens included.