How It Works
A concrete walkthrough of how CSRF works at the HTTP level, why same-site cookies + tokens block it, and the seams where bugs creep in. Use as the teaching backbone when an engineer asks "why does the framework do this for me?".
CSRF at the HTTP level
EXAMPLE
# ===== The mechanism ===== # 1) Victim is signed in to https://shop.example # Server set a cookie: Set-Cookie: sid=abc; HttpOnly; Secure; SameSite=Lax # 2) Victim visits https://attacker.example # 3) Attacker page contains an auto-submitted form (or <img src> for GET): # # <form action='https://shop.example/account/email' method='POST'> # <input name='to' value='attacker@evil'> # </form> # <script>document.forms[0].submit()</script> # # 4) Browser sends the POST. Without SameSite restrictions, the browser # ATTACHES sid=abc because the request goes to shop.example, where # the cookie belongs. The server sees an authenticated POST and acts. # Result: a state change happens under the victim's identity without their # knowledge or consent. # ===== Why same-site cookies block it ===== # Modern browsers default cookies to SameSite=Lax. # Lax cookies are ATTACHED only on: # - Same-site requests # - Top-level GET navigations (clicking a link to shop.example) # Lax cookies are NOT attached on: # - Cross-site POST/PUT/PATCH/DELETE # - Cross-site <img>, <script>, <iframe> requests # Effect: the forged POST to shop.example from attacker.example arrives # WITHOUT the session cookie. The server sees an anonymous request and 401s. # Strict adds protection on top-level navigation too — at the cost of # 'click the link in your email, end up signed out'. # ===== Why CSRF tokens add a second layer ===== # Server stores a random per-session 'CSRF token'. Every state-changing form # embeds the token. Server rejects writes whose token does not match. # Attacker.example does NOT know the token; cannot forge it. # Even if SameSite were somehow weakened (browser bug, downgrade), the token # requirement still blocks the forgery. # ===== The double-submit pattern (SPAs) ===== # Common for SPAs that POST via fetch: # - Server sets XSRF-TOKEN cookie (NOT HttpOnly, so JS can read it) # - SPA reads it and sends it back as a custom header (X-XSRF-TOKEN) # - Server verifies the cookie value matches the header value # Why it works: a cross-origin attacker cannot read the cookie (CORS rules # block JS reads cross-origin) AND cannot set a custom header on a cross-site # form submission (CORS preflight required). # ===== Bearer-token APIs (Authorization: Bearer) ===== # CSRF is structurally impossible. The browser does NOT auto-attach # 'Authorization: Bearer ...' on cross-origin requests. The attacker site # cannot mint a header it does not have. # Still defend against: token leakage via XSS, link prefetching, log leaks. # ===== Webhook signatures (Stripe, etc.) ===== # Not CSRF surface — there is no cookie auth. Verify the signature header # with HMAC and constant-time compare; treat the signature as the auth. # EXEMPT webhooks from the CSRF middleware OR they'll reject every callback. # ===== Where bugs sneak in ===== # 1) GET endpoint that mutates state (logout, delete) # -> attacker uses <img src=> to trigger # -> change to POST + CSRF token # 2) SameSite=None to fix a cross-domain login flow # -> Lax protection now off; restore via token + Origin check # 3) CSRF middleware allow-list expanded for a webhook, accidentally # captures a wider path # -> regex anchored to one route only; never a prefix # 4) CORS allow_credentials: true with wildcard or too-broad origins # -> cookies travel to attacker.example # -> exact origin list, never '*' # 5) JSON API that accepts requests without an Authorization header # -> someone added cookie auth as a 'convenience', re-introducing CSRF # -> pick one auth model per app and enforce it in middleware # ===== Quick layered defence (do all four) ===== # 1) HTTP method discipline: GET = read; POST/PUT/PATCH/DELETE = write # 2) Session cookie: SameSite=Lax (or Strict for admin) + Secure + HttpOnly # 3) CSRF token: framework default middleware enabled # 4) CORS: exact origin list, supports_credentials only where required # ===== Self-test ===== # - Can you change your email at https://shop.example via a forged form on # attacker.example? -> if yes, you have a bug. # - Can you log a user out by triggering a GET? -> bug. # - Can a webhook endpoint be hit by anyone? -> verify the signature.
Why it matters
The CSRF defence chain is HTTP method discipline + SameSite=Lax cookies + a CSRF token + a tight CORS allow-list. Each catches what the others miss. Drop any one and you have a single point of failure; keep all four and the class of bug becomes a "we found a CSP violation in our staging logs" footnote instead of an incident.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
<!-- victim is logged in to bank.com -->
<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>
Try it Yourself »
Discussion
Loading…