Web Application Security · intermediate · ~10 min
**What you will learn** - Explain how CSRF abuses *ambient authority* — the browser automatically attaching cookies to a request — to forge authenticated, state-changing actions. - Draw the CSRF threat model: assets, trust boundary, entry point, and the insecure assumption that makes the attack work. - Recognise a CSRF-vulnerable endpoint by inspecting how it authenticates and authorises a state-changing request. - Implement and verify the standard defenses: synchronizer (anti-CSRF) tokens, `SameSite` cookies, and origin checking. - Prove a fix works by testing that a forged cross-site request is *rejected* while a legitimate same-site request is *accepted*. - Log and detect CSRF attempts safely, without recording secrets, in a local authorized lab.
Security objective. The asset we protect is the integrity of state-changing actions performed under a logged-in user's identity — transfers, password/email changes, role grants, deletions. The threat is an attacker who cannot steal the session but can make the victim's own browser send a request the victim never intended. By the end you will be able to detect endpoints that trust auto-sent cookies alone, and prevent the abuse with tokens, SameSite, and origin checks.
What CSRF is. Cross-site request forgery tricks a victim's browser into sending an authenticated, state-changing request to a site the victim is logged into — without the victim's intent. It works because browsers attach cookies automatically to every request for a domain, regardless of which page triggered that request. This automatic attachment is called ambient authority: the server trusts the request simply because valid credentials rode along on their own.
Where it fits. This builds directly on your prereqs. From Cookies, sessions, and browser storage you know that a session cookie is what keeps a user logged in and that the browser sends it back on every matching request — that auto-send is precisely the mechanism CSRF abuses. From Testing the web: intercepting and shaping requests you know how to observe and shape an HTTP request; here you use that skill to see whether an endpoint accepts a request that carries a cookie but no proof the user's own page built it.
What you will do about it. The lesson follows an insecure → secure → verify shape. You will see a minimal vulnerable endpoint, add the standard defenses, then run checks that confirm a forged request is rejected and a legitimate one still works. Everything runs on localhost only.
CSRF turns a victim's logged-in session into a weapon. Because the request is fully authenticated by the browser, the server sees a normal, valid action: a money transfer, a password reset, an email-address change that quietly enables account takeover, an admin granting themselves a role. The victim did nothing but visit a web page in another tab.
In authorized professional work — a penetration test or a secure code review — CSRF is a staple finding on any app that performs state changes with cookie-based sessions. Reviewers check every state-changing endpoint for a token and for SameSite protection; testers confirm whether a cross-origin request is honoured. The severity is not automatic: a CSRF on a low-value toggle is minor, while CSRF on "change email" or "transfer funds" can be high or critical because it leads to account takeover or direct financial loss. Rating it correctly requires reasoning about what the action does, not just that the bug exists.
The deeper payoff is a mental model you reuse everywhere: once you see that the attack depends on credentials being sent automatically, you understand both why the bug exists and why every good defense works by requiring proof that the legitimate first-party page — not some attacker's page — originated the request.
Definition. Authority that a request carries automatically, without the code that triggered the request having to present it deliberately. Session cookies are the classic example.
Plain explanation. When you log into bank.example, the server sets a session cookie. From then on the browser attaches that cookie to every request to bank.example — whether the request comes from the bank's own page, a link, an image tag, or a hidden form on a completely different site. The credential is "ambient": it is in the air around any request to that origin.
How it works. The browser's cookie-sending rule is keyed to the destination origin, not the originating page. So a POST built by attacker.example and aimed at bank.example still arrives with the bank's session cookie attached.
When / when not. Ambient authority is convenient (you stay logged in) but dangerous for state-changing actions. Bearer-token APIs avoid it: the token lives in JavaScript memory or storage and is added deliberately by an Authorization header, so it is not ambient and an attacker's page cannot supply it.
Pitfall. Believing "the request was authenticated, so the user must have intended it." Authentication answers who; it does not answer did this user's own page ask for this. CSRF lives in that gap.
Definition. An attacker-controlled page that silently causes the victim's browser to issue a request to the target site.
Plain explanation. The attacker does not need to touch the victim's cookies or the target server. They only need the victim to load a page they control (a link, an ad, a comment with embedded HTML). That page auto-submits a form or fires a request to the target.
How it works. A hidden <form> with method="POST" and an action pointing at the target, submitted by a one-line script on load. No user click is required. A state-changing GET is even easier to forge — it can hide inside an <img src=...>.
When / when not. Works only if the target action can be fully predicted and built in advance, and depends solely on ambient credentials. If a secret unpredictable value is required, the attacker cannot construct the request.
Pitfall. Thinking "POST-only" or "requires JSON" is a defense. Browsers can auto-submit POSTs, and simple forms can send bodies the server may accept. These raise the bar slightly but do not close the hole.
Definition. An unpredictable, per-session (or per-request) secret that the server issues to its own pages and requires back on every state-changing request.
Plain explanation. The server plants a random token in the form (a hidden field) or exposes it to first-party JavaScript. A real request echoes it; the server compares it to the value bound to the session. An attacker's page cannot read the token (that would require reading the target's page across origins, which the Same-Origin Policy forbids) and cannot guess it, so the forged request fails the check.
How it works. Generate the token with a cryptographically secure random source, bind it to the user's session server-side, and reject any state-changing request whose token is missing or does not match. Compare in constant time.
When / when not. The default for cookie-session, form-based apps. Not needed for pure bearer-token APIs (no ambient credential to abuse), though a token does no harm.
Pitfall. Putting the token only in a cookie and reading it from that same cookie server-side — that is not a synchronizer token and can be defeated. The token must come from a place the attacker's page cannot forge, and be compared to session-bound state. (The double-submit pattern works only when the cookie is protected against sub-domain and injection tampering.)
Definition. A cookie attribute (Strict, Lax, or None) that tells the browser whether to attach the cookie on cross-site requests.
Plain explanation. SameSite=Strict withholds the cookie on any cross-site request. SameSite=Lax (the modern browser default when unset) withholds it on cross-site subrequests like forms and images but sends it on top-level navigations via safe methods. SameSite=None sends it everywhere and must be paired with Secure.
How it works. The browser compares the site of the page initiating the request with the site of the cookie. If they differ and the policy forbids it, the cookie is dropped — so the forged request arrives unauthenticated and is rejected.
When / when not. A strong baseline for every session cookie. Lax breaks few legitimate flows; Strict is best for the most sensitive cookies but can log users out when arriving from external links. Do not rely on it alone if you must support very old browsers — layer it with tokens.
Pitfall. "Same-site" is not "same-origin": a.example and b.example are cross-site, but app.bank.example and pay.bank.example are same-site. A sibling subdomain you do not fully control can still count as same-site.
Definition. Server-side validation that a state-changing request was initiated by an allowed origin, using the Origin (and as a fallback Referer) header the browser sets.
Plain explanation. For cross-origin requests, browsers attach an Origin header the page cannot forge. The server checks it against an allow-list and rejects mismatches. It is a useful defense-in-depth layer, not a sole defense.
Pitfall. Origin can be absent on some same-origin or legacy requests, and Referer can be stripped by privacy settings. Treat a mismatch as a reject, but decide carefully how to handle absent — usually require the token in that case rather than fail open.
TRUST BOUNDARY (browser origin / SOP)
|
ATTACKER SIDE | TARGET SIDE (asset)
------------- | ------------------
attacker.example --- loads -->| Victim browser --- POST /transfer ---> bank.example
(hidden auto-form) | (holds bank cookie auto-attached server
ENTRY POINT: victim | session cookie) ==================> |
visits attacker page | v
| ASSET: state-changing action
| (funds, password, email, role)
|
Attacker CANNOT read the | Insecure assumption: "a request carrying a
bank page or its token | valid cookie was intended by the user."
(SOP blocks cross-origin read) | Defenses restore the missing proof of
| first-party intent (token / SameSite / Origin).
Knowledge check
The core defensive pattern is: (1) set the session cookie with a SameSite policy, (2) embed a synchronizer token in first-party forms, and (3) require and compare that token server-side on every state-changing request.
# 1) Server response that establishes the session — note the cookie attributes
HTTP/1.1 200 OK
Set-Cookie: session=<opaque-id>; HttpOnly; Secure; SameSite=Lax; Path=/
# ^HttpOnly: JS can't read it ^Secure: HTTPS only ^SameSite: withhold cross-site
<!-- 2) First-party form carries an unpredictable token the attacker cannot read or guess -->
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="{{ per_session_random_token }}">
<input name="to"><input name="amount">
<button>Send</button>
</form>
# 3) Server-side rule (pseudocode) for EVERY state-changing request:
if request.method in {POST, PUT, PATCH, DELETE}:
if not constant_time_equals(request.form["csrf_token"], session["csrf_token"]):
reject 403 # missing or wrong token => forged
if Origin present and Origin not in ALLOWED_ORIGINS:
reject 403 # defense in depth
Key points: generate the token from a cryptographically secure RNG, bind it to the server-side session, compare in constant time, and never accept a state-changing GET.
CSRF tricks a logged-in victim's browser into making a state-changing request to a site they are authenticated to, without their intent.
Browsers attach cookies automatically to every request for a domain.
Suppose a victim is logged into bank.com. They then visit an attacker's page that auto-submits a hidden form:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker"><input name="amount" value="1000">
</form><script>document.forms[0].submit()</script>
The bank receives a fully authenticated transfer. The session cookie rode along with the request.
This is ambient authority in action: the request is accepted because the browser authenticated it on its own.
CSRF works only when both of these hold:
Bearer-token APIs are largely immune. There, the token is added by JavaScript, not sent automatically, so the attacker's page cannot supply it.
Lax or Strict) — stop the cookie from being sent on cross-site requests. This is a strong modern baseline.Checking GET versus POST is not enough. A state-changing GET is itself a bug.
The example is a tiny Flask app so the request/response flow is visible. It runs on 127.0.0.1 only. Read it as insecure → secure → verify.
# vulnerable_app.py --- runs on http://127.0.0.1:5000 (lab only)
from flask import Flask, request, session
app = Flask(__name__)
app.secret_key = "lab-only-not-a-real-secret" # placeholder; never commit real secrets
BALANCES = {"alice": 1000, "attacker": 0}
@app.route("/login")
def login():
session["user"] = "alice" # pretend alice logged in
return "logged in as alice"
@app.route("/transfer", methods=["POST"])
def transfer():
# INSECURE: trusts the session cookie alone. No proof the request
# came from our own page. Any cross-site form can drive this.
user = session.get("user")
if not user:
return "not logged in", 401
to, amount = request.form["to"], int(request.form["amount"])
BALANCES[user] -= amount
BALANCES[to] = BALANCES.get(to, 0) + amount
return f"transferred {amount} to {to}"
An attacker page hosted anywhere (in the lab, a second local port) could contain:
<!-- attacker.html (lab only) — auto-submits to the victim's session -->
<form action="http://127.0.0.1:5000/transfer" method="POST">
<input name="to" value="attacker"><input name="amount" value="1000">
</form><script>document.forms[0].submit()</script>
Because the browser auto-attaches alice's session cookie, the transfer succeeds even though alice never asked for it. Expected behaviour: the balance moves.
# secure_app.py --- runs on http://127.0.0.1:5000 (lab only)
import secrets
from flask import Flask, request, session, abort
app = Flask(__name__)
app.secret_key = "lab-only-not-a-real-secret" # placeholder
# SameSite=Lax + HttpOnly on the session cookie: withheld on cross-site subrequests
app.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_HTTPONLY=True)
BALANCES = {"alice": 1000, "attacker": 0}
ALLOWED_ORIGINS = {"http://127.0.0.1:5000"}
@app.route("/login")
def login():
session["user"] = "alice"
session["csrf"] = secrets.token_urlsafe(32) # unpredictable, session-bound
return "logged in; token issued"
@app.route("/form")
def form():
# first-party page embeds the token the attacker cannot read (SOP) or guess
return f'''<form action="/transfer" method="POST">
<input type="hidden" name="csrf" value="{session.get("csrf","")}">
<input name="to"><input name="amount"><button>Send</button></form>'''
def check_csrf():
origin = request.headers.get("Origin")
if origin is not None and origin not in ALLOWED_ORIGINS:
abort(403) # defense in depth
sent = request.form.get("csrf", "")
good = session.get("csrf", "")
if not good or not secrets.compare_digest(sent, good): # constant-time compare
abort(403) # missing/wrong token => forged
@app.route("/transfer", methods=["POST"])
def transfer():
user = session.get("user")
if not user:
return "not logged in", 401
check_csrf()
to, amount = request.form["to"], int(request.form["amount"])
BALANCES[user] -= amount
BALANCES[to] = BALANCES.get(to, 0) + amount
return f"transferred {amount} to {to}"
# Lab only, against 127.0.0.1. Uses a cookie jar to simulate a logged-in browser.
# Start: python secure_app.py (in another terminal)
# Log in and save the session cookie
curl -s -c jar.txt http://127.0.0.1:5000/login
# A) FORGED request: valid cookie, but NO token => must be rejected (403)
curl -s -o /dev/null -w "forged no-token -> %{http_code}\n" \
-b jar.txt -X POST http://127.0.0.1:5000/transfer \
-d "to=attacker&amount=1000"
# B) FORGED request: valid cookie, WRONG token => must be rejected (403)
curl -s -o /dev/null -w "forged bad-token -> %{http_code}\n" \
-b jar.txt -X POST http://127.0.0.1:5000/transfer \
-d "csrf=guessed&to=attacker&amount=1000"
# C) FORGED cross-origin: attacker Origin header => must be rejected (403)
curl -s -o /dev/null -w "cross-origin -> %{http_code}\n" \
-b jar.txt -H "Origin: http://attacker.example" -X POST \
http://127.0.0.1:5000/transfer -d "to=attacker&amount=1000"
# D) LEGIT request: read the real token from /form, then submit it => must succeed (200)
TOKEN=$(curl -s -b jar.txt http://127.0.0.1:5000/form | grep -o 'value="[^"]*"' | head -1 | cut -d'"' -f2)
curl -s -o /dev/null -w "legit token -> %{http_code}\n" \
-b jar.txt -H "Origin: http://127.0.0.1:5000" -X POST \
http://127.0.0.1:5000/transfer -d "csrf=$TOKEN&to=bob&amount=10"
Expected results: A, B, and C print 403 (forged requests rejected); D prints 200 (the legitimate first-party request with a valid token is accepted). That contrast — bad rejected, good accepted — is your mitigation verification.
Stop both servers (Ctrl-C), delete the cookie jar (rm jar.txt), and discard the attacker.html file. The in-memory BALANCES reset on restart; nothing persists.
Walking the secure app and the verification.
| Step | Code | What happens | Why it matters |
|---|---|---|---|
| 1 | SESSION_COOKIE_SAMESITE="Lax" |
Browser is told to withhold the session cookie on cross-site subrequests (like a forged form POST) | The forged request arrives with no cookie, so it is not even authenticated |
| 2 | session["csrf"] = secrets.token_urlsafe(32) at login |
A ~256-bit random token is generated and stored server-side, bound to this session | Unpredictable + server-bound = attacker cannot guess or forge it |
| 3 | /form embeds value="{csrf}" |
The token is placed in the first-party HTML | Only a page served by our origin can contain the correct token; SOP stops the attacker reading it |
| 4 | check_csrf() reads Origin |
If a cross-origin Origin is present and not allow-listed, abort(403) |
Catches obvious cross-origin forgeries as a first, cheap layer |
| 5 | secrets.compare_digest(sent, good) |
Compares the submitted token to the session token in constant time | Missing/wrong token → 403; constant-time avoids leaking the token via timing |
| 6 | transfer() runs only after check_csrf() |
State change happens only when the token proves first-party intent | Authentication and intent are now both required |
Value trace through the verification requests:
| Request | Cookie sent? | session["csrf"] |
Token submitted | compare_digest |
Result |
|---|---|---|---|---|---|
| A no-token | yes (curl -b) |
T |
"" |
false | 403 |
| B bad-token | yes | T |
"guessed" |
false | 403 |
| C cross-origin | yes | T |
(n/a, Origin fails first) | — | 403 |
| D legit | yes | T |
T (read from /form) |
true | 200 |
Note that in a real browser (not curl), request A would also fail earlier because SameSite=Lax would strip the cookie on a cross-site POST. curl does not enforce SameSite, which is why the token check is the load-bearing server-side defense — you should never rely on the browser alone.
1. Checking method instead of intent.
GET can be forged with an <img> tag.GET change state.GET routes that write data; treat each as a finding.2. Token stored only in a cookie, then read from that cookie.
__Host- prefix, Secure, and integrity.3. Treating SameSite=Lax as a complete fix.
SameSite=Lax and remove all tokens.Lax still allows top-level navigations, some legacy browsers ignore it, and a same-site (sibling subdomain) attacker bypasses it.SameSite and tokens as layered defenses.4. Non-constant-time token comparison.
if sent == good.secrets.compare_digest / hmac.compare_digest or the platform equivalent.5. Failing open when Origin is absent.
Origin header, so allow it."Common errors and how to chase them:
Cookie request header). A rotated or expired session drops the token binding.session["csrf"] (session not persisting because secret_key changed between requests or the cookie is not being stored). Confirm the login response set a cookie and the client reused it.SameSite, so the cookie is sent; if the token check also passes, you accidentally supplied a real token. Re-run request A with no csrf field and confirm 403.Origin to block it. Same-site siblings (pay.bank.example vs app.bank.example) are not cross-site; verify your allow-list is at the right granularity.Questions to ask when a CSRF defense fails:
HttpOnly, Secure, and a SameSite policy?GET)?Security & safety — detection and logging.
A rejected CSRF attempt is a signal worth capturing. On every state-changing request, log a structured event:
POST /transfer), the security decision (csrf_ok / csrf_reject and the reason: missing / mismatch / origin), the Origin/Referer values, and a correlation id to tie related events together.Which events signal abuse: a burst of csrf_reject events for one user across many endpoints, rejects whose Origin is an unfamiliar external site, or state-changing requests arriving with a valid session cookie but consistently missing tokens. Cross-referencing with the referring page (in a lab you control) helps confirm a forgery attempt versus a bug.
How false positives arise: users with aggressive privacy extensions that strip Referer/Origin; stale browser tabs submitting an expired token after a session rotation; a legitimate SPA that forgot to attach the token on a new call; multi-tab logins that rotate the token. Tune by distinguishing expired/rotated token (likely benign) from no token from an external origin (likely malicious), and by rate context rather than treating every single reject as an incident.
Authorization reminder: generate and inspect these logs only on systems you own or are explicitly authorized to test. In the lab, keep logs local and delete them during cleanup.
Authorized real-world use case. In a scoped web-app penetration test, you enumerate every state-changing endpoint (transfer, change-email, change-password, role-grant, delete) and check each for CSRF protection. For one endpoint you craft a same-request from a local test page inside your own test tenant, confirm whether the target honours it, and — critically — you also verify the fix by re-testing after remediation. You then write it up with a severity that reflects the action's impact (change-email enabling account takeover is far higher than toggling a non-sensitive preference).
Professional best-practice habits.
| Habit | Beginner | Advanced |
|---|---|---|
| Input & intent validation | Add a synchronizer token to every form | Per-request tokens for the highest-value actions; reject state-changing GETs framework-wide |
| Secure defaults | Set SameSite=Lax, HttpOnly, Secure on session cookies |
Use __Host- cookie prefix; centralise CSRF middleware so new routes are protected by default |
| Least privilege | Require re-authentication for sensitive changes | Step-up auth / short-lived confirmation tokens for money movement and credential changes |
| Logging & detection | Log every csrf_reject with reason (no secrets) |
Alert on reject bursts and unfamiliar Origins; correlate with session anomalies |
| Error handling | Return a clean 403 without leaking token details | Fail closed on missing headers; safe generic error page; retest after every fix |
Ethics/authorization: only test systems you own or have explicit written permission to test. Labs must run on localhost, containers, intentionally-vulnerable VMs, or CTF targets — never against third-party sites. Remember: passing an automated CSRF scanner does not prove an app is secure, and no control makes an app "completely secure" — layered defenses reduce risk, they do not eliminate it.
All tasks are lab-only, on 127.0.0.1 or containers you control. Each ends with a defensive conclusion: remediate, then verify.
Beginner 1 — Read the cookie attributes.
Set-Cookie header.HttpOnly, Secure, SameSite are present or missing.curl -v.SameSite. Defensive conclusion: state which attribute you would add and why.Beginner 2 — Confirm the vulnerability, then reason about the fix.
/transfer accepts a cookie-only request.Intermediate 1 — Add and verify a synchronizer token.
/form, compare with compare_digest.Intermediate 2 — Layer SameSite + Origin checking and prove it.
SameSite=Lax and an Origin allow-list on top of the token.Origin is external; still accept the first-party one.Origin → 403; allowed Origin + token → 200.Origin is absent.curl -H "Origin: ...".SameSite, origin checking, defense in depth. Defensive conclusion: note why you keep the token even after adding these.Challenge — Detection and safe reporting.
/transfer, log timestamp, session id (opaque), method+path, security decision + reason, Origin, correlation id — never the token or cookie. Then produce a short finding using the template: title, severity (justified by impact), affected component, preconditions, safe reproduction (lab only), evidence (log excerpt), impact, likelihood, remediation, retest steps.Main concepts. CSRF abuses ambient authority — the browser auto-attaching the session cookie — to forge authenticated, state-changing requests the user never intended. It works only when the action depends solely on auto-sent credentials and is predictable enough to build in advance. The insecure assumption is conflating authentication (who) with intent (did the user's own page ask for this).
Key defenses / syntax. Synchronizer (anti-CSRF) tokens: unpredictable, session-bound, compared in constant time on every state-changing request. SameSite cookies (Lax/Strict) to withhold the cookie cross-site. Origin/Referer checking as defense in depth. Bearer-token APIs are largely immune because the token is not ambient. Layer these; never rely on one.
Common mistakes. Trusting method (POST-only) instead of intent; letting GET change state; storing the token only in a cookie; treating SameSite alone as sufficient; non-constant-time comparison; failing open when a header is absent.
What to remember. Every state-changing endpoint needs proof of first-party intent. Verify a fix by showing the forged request is rejected (403) and the legitimate one is accepted (200). Log rejects safely (never the token, cookie, or other secrets), and rate severity by the action's impact. Test only on systems you own or are authorized to test, in a local lab — and remember no control makes an app "completely secure."