Web Application Security · intermediate · ~10 min

Cross-site request forgery (CSRF)

**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.

Overview

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.

Why it matters

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.

Core concepts

1. Ambient authority

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.

2. The CSRF trigger

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.

3. Synchronizer (anti-CSRF) tokens

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.)

4. SameSite cookies

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.

5. Origin / Referer checking

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.

Threat model

                    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

  1. What asset is protected? The integrity of authenticated state-changing actions performed under the victim's identity (e.g. transfers, credential changes).
  2. What insecure assumption causes the bug? That a request carrying a valid session cookie must have been intended by the user — conflating authentication with intent.
  3. Where is the trust boundary, and why can't the attacker read the token across it? At the browser's origin boundary enforced by the Same-Origin Policy; the attacker's page may send a request to the target but cannot read the target's response or embedded token.
  4. Why must this be tested only in an authorized lab? Forging state-changing requests against a system you do not own or have written permission to test is unauthorized access and can cause real financial or account harm.

Syntax notes

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.

Lesson

CSRF tricks a logged-in victim's browser into making a state-changing request to a site they are authenticated to, without their intent.

The mechanism

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.

Conditions for the attack

CSRF works only when both of these hold:

  • The action relies solely on auto-sent credentials (cookies).
  • The request is predictable, so the attacker can build it in advance.

Bearer-token APIs are largely immune. There, the token is added by JavaScript, not sent automatically, so the attacker's page cannot supply it.

The defenses

  • Anti-CSRF tokens — an unpredictable per-session or per-request value the attacker cannot guess, required on every state-changing request.
  • SameSite cookies (Lax or Strict) — stop the cookie from being sent on cross-site requests. This is a strong modern baseline.
  • Re-authentication or confirmation — require it for sensitive actions.

Checking GET versus POST is not enough. A state-changing GET is itself a bug.

Code examples

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.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# 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.

(2) SECURE fix — SameSite cookie + synchronizer token + Origin check

# 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}"

(3) VERIFY — the fix rejects forged input and accepts good input

# 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.

Lab cleanup / reset

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.

Line by line

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.

Common mistakes

1. Checking method instead of intent.

  • Wrong: "Only POST changes state, so we are safe."
  • Why wrong: Browsers auto-submit POST forms; a hidden form is a POST. And any state-changing GET can be forged with an <img> tag.
  • Corrected: Require a synchronizer token on every state-changing request, and never let GET change state.
  • Recognise/prevent: Grep the codebase for GET routes that write data; treat each as a finding.

2. Token stored only in a cookie, then read from that cookie.

  • Wrong: Put the CSRF token in a cookie and, server-side, compare the submitted value to the cookie value with no session binding.
  • Why wrong: Cookies are ambient too; if the attacker can set or predict the cookie (e.g. via a sibling subdomain), the check passes.
  • Corrected: Bind the token to server-side session state and compare against that. If you use double-submit, protect the cookie with __Host- prefix, Secure, and integrity.
  • Recognise/prevent: Ask "could an attacker page influence both sides of this comparison?"

3. Treating SameSite=Lax as a complete fix.

  • Wrong: Set SameSite=Lax and remove all tokens.
  • Why wrong: Lax still allows top-level navigations, some legacy browsers ignore it, and a same-site (sibling subdomain) attacker bypasses it.
  • Corrected: Use SameSite and tokens as layered defenses.
  • Recognise/prevent: Never rely on a single control for a high-value action.

4. Non-constant-time token comparison.

  • Wrong: if sent == good.
  • Why wrong: Ordinary string comparison can leak the token byte-by-byte through timing in some environments.
  • Corrected: secrets.compare_digest / hmac.compare_digest or the platform equivalent.

5. Failing open when Origin is absent.

  • Wrong: "No Origin header, so allow it."
  • Why wrong: Some legitimate and some crafted requests omit it; allowing on absence weakens the control.
  • Corrected: Fall back to the mandatory token check; never skip verification just because a header is missing.

Debugging tips

Common errors and how to chase them:

  • Legitimate form suddenly returns 403. The token in the page does not match the session. Check that the token is issued at login/session-start and re-embedded on every form render, and that the session cookie is actually being sent back (look at the Cookie request header). A rotated or expired session drops the token binding.
  • Everything is 403, even correct tokens. You may be comparing against an empty 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.
  • Forged request unexpectedly succeeds in your test. curl ignores 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.
  • Cross-origin request passes in a browser but you expected 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:

  1. Is the token generated from a cryptographically secure source and bound to the server-side session?
  2. Is the comparison constant-time, and does a missing token count as a failure (not a skip)?
  3. Is the session cookie set with HttpOnly, Secure, and a SameSite policy?
  4. Does any state-changing route bypass the check (a new endpoint, a JSON handler, a legacy GET)?
  5. When you send a request with a valid cookie but no/invalid token, do you get a clean 403 — and is that event logged?

Memory safety

Security & safety — detection and logging.

A rejected CSRF attempt is a signal worth capturing. On every state-changing request, log a structured event:

  • Log: timestamp (UTC), source IP / connection id, authenticated user or session id (an opaque id, not the raw cookie), the resource and method (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.
  • Never log: the CSRF token itself, the session cookie value, passwords, bearer/API tokens, private keys, full payment card numbers (PANs), or unneeded PII. Logging the token or cookie would hand an attacker the very secret the control depends on.

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.

Real-world uses

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.

Practice tasks

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.

  • Objective: Identify whether a session cookie is CSRF-hardened.
  • Requirements: Start the vulnerable lab app, log in, and capture the Set-Cookie header.
  • Input/output: Input = the raw response header; output = a short note listing which of HttpOnly, Secure, SameSite are present or missing.
  • Constraints: Observe only; do not modify the server yet.
  • Hints: Use your intercepting proxy or curl -v.
  • Concepts: ambient authority, SameSite. Defensive conclusion: state which attribute you would add and why.

Beginner 2 — Confirm the vulnerability, then reason about the fix.

  • Objective: Demonstrate that the vulnerable /transfer accepts a cookie-only request.
  • Requirements: Send a POST to the lab endpoint with the saved cookie and no token; observe success.
  • Input/output: Input = cookie + form body; output = the balance change.
  • Constraints: Lab app only; never a real site.
  • Hints: Reuse the cookie jar from Beginner 1.
  • Concepts: the trigger, ambient authority. Defensive conclusion: write one sentence on the missing proof-of-intent.

Intermediate 1 — Add and verify a synchronizer token.

  • Objective: Patch the endpoint to require a session-bound token.
  • Requirements: Generate the token at login with a secure RNG, embed it in /form, compare with compare_digest.
  • Input/output: A no-token request → 403; a valid-token request → 200.
  • Constraints: Constant-time comparison; missing token counts as failure.
  • Hints: Store the token in the session, not a plain cookie.
  • Concepts: synchronizer tokens. Defensive conclusion: run both the reject and accept checks and record the status codes.

Intermediate 2 — Layer SameSite + Origin checking and prove it.

  • Objective: Add SameSite=Lax and an Origin allow-list on top of the token.
  • Requirements: Reject a request whose Origin is external; still accept the first-party one.
  • Input/output: External Origin → 403; allowed Origin + token → 200.
  • Constraints: Fail closed on external origins; fall back to the token when Origin is absent.
  • Hints: Test with curl -H "Origin: ...".
  • Concepts: SameSite, origin checking, defense in depth. Defensive conclusion: note why you keep the token even after adding these.

Challenge — Detection and safe reporting.

  • Objective: Add structured logging that flags likely CSRF abuse without recording secrets, and write a mini finding report.
  • Requirements: On each /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.
  • Constraints: No secrets in logs; severity must be argued from exploitability and impact, not assumed critical.
  • Hints: Simulate a reject burst by sending several no-token requests; confirm your log distinguishes missing token from external origin.
  • Concepts: detection, logging hygiene, reporting. Defensive conclusion: include a retest that shows the forged request is now rejected and the legitimate one still succeeds.

Summary

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."

Practice with these exercises