Web and API security
OWASP Top 10 Explained for Developers
What the OWASP Top 10 is, what each 2025 category means, how to defend it, and how to map Juice Shop challenges to it.
Cross-site request forgery uses your browser's cookies against you. Learn the defences in order of strength and test SameSite with a Flask lab.
Cross-site request forgery (CSRF) lets a page on one site make your browser send a state-changing request to another site where you are logged in. The browser attaches your session cookie on its own, so the other site treats the request as yours. The defences are a per-session secret the attacker cannot read, the SameSite cookie attribute, an Origin check, and a rule that GET requests never change state. This article covers each one, then runs a small Flask lab so you can see the cookie move.
A web session usually lives in a cookie. After you log in to bank.example, your browser stores a cookie for that host and sends it with every request to that host. The server reads the cookie, finds your session, and runs the action.
The browser does not check where a request came from. If a page on evil.example contains a form that posts to https://bank.example/transfer, the browser sends that POST with your bank.example cookie attached. The bank sees a valid session and moves the money. The attacker never sees your cookie and never reads the response. The attack only needs the server to perform the action.
CWE-352 describes the weakness this way: the application performs a state-changing action for a user without checking that the user chose to make the request. The fix is to let the server tell a request the user made apart from one a third-party page forged.
Any app that keeps sessions in cookies and changes data on a request has this exposure. The actions that matter most are the ones that change credentials or settings: a password change, an email address change, a new API key, a new payment method, a forwarding rule. A forged email change is often the first step of an account takeover, because the attacker can then reset the password through the new address.
Cookie sessions are the default in many frameworks, and the browser sends the cookie whether or not the developer thought about it. That is why CSRF still appears on the OWASP and CWE lists.
bank.example. The browser stores the session cookie for that host.https://bank.example/transfer with values the attacker chose. It can submit on load, so the victim does not need to click anything.bank.example cookie.Notice what the attacker did not need. They did not steal the cookie, guess a password or read a single byte of the bank's response. The browser did the work.
A GET request can be triggered without any form. An <img src="https://bank.example/delete?id=7"> tag makes the browser send a GET request as soon as the page renders. Prefetching, link previews and crawlers can send GET requests too. If a GET route changes data, CSRF needs no form at all.
The rule is simple: GET and HEAD read data and never change it. Changes go through POST, PUT, PATCH or DELETE. The rule is not a defence on its own, because a POST can still be forged, but every other defence works better when the rule holds.
The server creates a random token for each session, puts it in every form as a hidden field, and checks it on every state-changing request. A request without the right token is refused.
The token works because the attacker's page cannot read it. The same-origin policy stops evil.example from reading the HTML that bank.example returned, so the attacker cannot copy the hidden field into a forged form. A forged request arrives with the cookie and without the token.
The token must be unpredictable (use a cryptographic random generator), bound to the user's session, and checked on every unsafe method, including the endpoints your JavaScript calls. Most frameworks ship this protection. The OWASP cheat sheet lists token-based defences first for that reason.
Double-submit cookies are a weaker variant. The token goes in both a cookie and a request field, and the server compares them. An attacker who can set cookies on your domain, for example from a sibling subdomain, can defeat it. Prefer the synchronizer token when you can keep state on the server.
SameSite tells the browser when to attach a cookie to a request that starts on another site. The attribute has three values.
Secure attribute, so the cookie travels only over HTTPS. Use None only when a real cross-site flow needs the cookie, such as an embedded third-party widget.MDN documents the attribute and its values in the Set-Cookie reference. The RFC 6265bis draft builds "site" on the registrable domain (the public suffix plus the label to its left, so site.example for www.site.example). Port numbers do not count. Subdomains count as the same site. This matters later in the lab, because localhost and 127.0.0.1 are different hosts and therefore different sites.
SameSite is strong, but not complete. A sibling subdomain such as forum.bank.example is same-site with bank.example, so Lax does not stop an attack launched from there. Older browsers that ignore the attribute send the cookie everywhere. And Lax still sends the cookie on top-level GET navigations, which is why the GET rule stays in force. Treat SameSite as one layer.
Browsers send an Origin header on POST requests and on most cross-site requests. The header holds the scheme, host and port of the page that started the request, so the server can compare it with its own origin. Sec-Fetch-Site gives a similar signal, with values such as same-origin, same-site and cross-site.
The check is simple. Reject state-changing requests when Origin is present and does not match your origin. If Origin is missing, fall back to Referer. If both are missing on a state-changing request, reject it. Parse the header as a URL and compare the full origin. A substring check fails: Origin: https://bank.example.evil.test contains your domain but belongs to someone else.
Origin checks catch clear cross-site requests, but they depend on headers that some clients omit and that privacy settings can strip. Use them as a filter, and keep a token for the cases the headers miss.
This is a rule, not a control you can test with a single request. Keep it because it makes every other defence cover more ground. A route that changes state only on POST gives an attacker a form to forge, not an image tag to plant.
You need Python 3.12 and three terminal windows. This article's run used Python 3.12.10 on Windows 11 with Git Bash. The lab uses one Flask app on 127.0.0.1:5000 and a plain static page on localhost:8001. The second origin is what makes the request cross-site.
Create a virtual environment and install Flask. On Linux and macOS, activate it with . .venv/bin/activate. On Windows with Git Bash, the activation script is at .venv/Scripts/activate.
python3 -m venv .venv && . .venv/bin/activate && pip install flaskOn this run the installed versions were Flask 3.1.3 and Werkzeug 3.1.9.
Save this as app.py. The /login route sets a session cookie with SameSite=Lax and HttpOnly. The /transfer route changes nothing. It only prints whether the cookie arrived, along with two request headers that help you read the result.
import os
from flask import Flask, make_response, request
app = Flask(__name__)
# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"
@app.get("/login")
def login():
resp = make_response("logged in\n")
resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
return resp
@app.post("/transfer")
def transfer():
arrived = request.cookies.get("session") == "lab"
print(
f"POST /transfer cookie_arrived={arrived} "
f"origin={request.headers.get('Origin')} "
f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
flush=True,
)
return f"cookie_arrived={arrived}\n"The app does not validate anything yet. That is deliberate. You want to see what the browser sends before you add a defence.
In the first terminal:
flask --app app run --host 127.0.0.1 --port 5000In a second terminal, request the login route and read the header:
curl -i http://127.0.0.1:5000/loginOutput from this run, with the Date line removed:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close
logged inThe Set-Cookie line is the important part. HttpOnly keeps the cookie away from page JavaScript. It does not stop the browser from sending the cookie, so HttpOnly is not a CSRF defence. SameSite=Lax is the attribute that changes what the browser sends across sites.
Before you involve a browser, send requests yourself so you know what the server logs:
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transferResponses from this run, one line per command:
cookie_arrived=False
cookie_arrived=True
cookie_arrived=TrueThe lines the Flask terminal printed for those three requests:
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origincurl does not apply SameSite. It sends whatever you pass with -b. So these lines prove that the route reads the cookie and the headers. They do not prove what a browser sends. That is the next step.
Create a folder named attacker and save this as attacker/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
<input type="hidden" name="amount" value="100">
<button type="submit">Send transfer</button>
</form>
</body>
</html>In a third terminal, start a static server from the folder that contains attacker/:
python3 -m http.server 8001 --bind 127.0.0.1On this run, a request to the static server returned HTTP 200. The page then loads at http://localhost:8001/attacker/. Note the host name. localhost and 127.0.0.1 are different hosts, so the form served from localhost:8001 posts to a different site than the cookie's host. That is the cross-site setup you need.
Open http://127.0.0.1:5000/login in your browser first, so the cookie is stored for 127.0.0.1 in that browser profile. Then open http://localhost:8001/attacker/ in the same browser and click Send transfer. Read the Flask terminal.
With SameSite=Lax, a cross-site form POST should not carry the cookie. The expected log line is cookie_arrived=False. The browser omits the cookie because Lax does not send it on cross-site POST requests. The browser step was not run for this article, so check the log line yourself against this expectation.
Now restart the app with None:
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000Read the header on the new server:
curl -i http://127.0.0.1:5000/loginOutput from this run:
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=NoneSameSite=None on its own is not enough. Browsers reject a None cookie that lacks Secure, and Secure requires HTTPS. The lab serves plain HTTP on 127.0.0.1, and whether a browser stores a Secure cookie from that origin depends on the browser. Open the cookie in your browser's developer tools and see whether it exists. If it was not stored, the server log shows cookie_arrived=False, but the reason is the Secure rule, not SameSite. If it was stored, run the form again. A cookie_arrived=True line with None means the browser sent a cookie on a cross-site POST. That is the behaviour that makes None risky for session cookies, and it is why you should use Lax or Strict unless a real cross-site flow needs None.
Stop each server with Ctrl+C when you finish.
The lab shows how cookies and SameSite change what the server receives. It does not add a defence. The /transfer route still accepts any POST that carries the cookie, so a SameSite=None cookie would let a forged form succeed. To close the gap, add a synchronizer token to the form and check it in the route. The lab stops here so you can see the attribute alone first.
evil-bank.example and bank.example.evil.test both contain bank.example. Compare full origins.Read the OWASP cheat sheet from start to finish. It covers the token patterns, the header checks and the cases each one misses. Read the RFC 6265bis draft for the exact SameSite rules, including the default when the attribute is missing and how top-level navigations are treated. The MDN Set-Cookie page is a faster reference for the attribute values.
In your own app, use the CSRF protection your framework provides, and test it. Write one test that posts to a state-changing route without a token and expects a refusal. Write a second test that posts with a token from a different session and expects a refusal too. If you use Flask, look at the CSRFProtect extension from Flask-WTF. Check the browser's developer tools for cookie warnings, because Chrome reports SameSite problems in its Issues panel.
For the transport layer under these cookies, read what HTTPS and TLS do. The Secure attribute only means something when the connection is encrypted. The next article in this series, how to write your first security report, shows how to record a finding like this one for the team that owns the code.
Web and API security
What the OWASP Top 10 is, what each 2025 category means, how to defend it, and how to map Juice Shop challenges to it.
Web and API security
How SQL injection changes a query's meaning, why parameterized queries fix it, and a Juice Shop login lab with a Python sqlite3 demo.
Web and API security
Learn reflected, stored and DOM-based XSS, why output encoding depends on context, and how CSP adds defence in depth, in a local lab.