Web Security (OWASP Top 10, fix-side) · lesson 2 of 6
Beginner
3Intermediate
3Also part of Web Security (OWASP Top 10, fix-side)
12- + Python Security from Python
- + Database Security from SQL
- + JavaScript Security Best Practices from JavaScript
- + Java Security from Java
- + Bash Security Best Practices from Bash
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + FastAPI Roles, CORS and Settings from Environment Variables from FastAPI
- + Spring Security — Authentication and Authorization from Java
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions from C
- + LLM Security Beyond Guardrails from LLM Engineering
- + Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype from DevSecOps (AppSec in the pipeline)
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
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.
A transfer endpoint that trusts the cookie
# 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
- Add an
Origincheck tocheck_csrf_token: if a POST has anOriginheader 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.
- 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.
- Change the cookie to
SameSite=Strict. What does theSet-Cookieheader 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.