Codetail

Article 4 of 12

Cross-Site Request Forgery (CSRF)

The browser sends the cookie, the attacker sends the request.

18 min read

The browser sends the cookie, the attacker sends the request

Cookies are scoped to a domain, not to whichever page happened to trigger the request that carries them. If you're logged into bank.example in one tab, and a completely unrelated page in another tab sends a request to bank.example, your browser attaches your bank session cookie to that request too. It doesn't ask which tab you meant. It just sees a request going to a domain it holds a cookie for.

An account email change, guarded by nothing but the session cookie

PythonVulnerable
1@app.route("/account/email", methods=["POST"])
2@login_required
3def change_email():
4 new_email = request.form["email"]
5 current_user.email = new_email
6 db.session.commit()
7 return redirect("/account")

This looks fine in isolation. It checks that the request came from a logged-in session, which is the only thing @login_required was ever going to check. It never asks whether the request was something the user actually meant to send.

A page hosted on a completely different domain, evil.example

HTMLVulnerable
1<form action="https://bank.example/account/email" method="POST" id="f">
2 <input type="hidden" name="email" value="attacker@evil.example">
3</form>
4<script>document.getElementById("f").submit();</script>

A victim who's logged into bank.example and gets lured to this page, an email link, a forum post, an ad, has the form auto-submit the moment the page loads. Nothing about the request looks forged from the server's side. It arrives as a normal POST, with a valid session cookie attached, because the browser attached it automatically. The user's email on the bank account changes to one the attacker controls, and the usual next step is a password reset flow that now goes straight to them.

The attacker never sees the victim's cookie, never steals a token, never touches the bank's servers directly. They just get the victim's own browser to send a request the victim never meant to send, and the browser cooperates, because attaching cookies to a request is exactly what browsers are supposed to do.

This only works because the endpoint has no way to tell "a request with a valid cookie" apart from "a request the user actually intended to make." Every defense in this article is some version of teaching it that difference.

CSRF tokens: proving the request came from your own form

A CSRF token is a random, unpredictable value the server generates and embeds in the real form. On submission, the server checks that the token in the request matches the one it issued for that session. The forged form from the last section has no way to know that value, so its request fails the check.

The real form, served by bank.example itself

HTML
1<form action="/account/email" method="POST">
2 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
3 <input type="email" name="email">
4 <button type="submit">Update email</button>
5</form>

Fixed: issue a token, then require it back

PythonFixed
1@app.route("/account/email", methods=["GET"])
2@login_required
3def email_form():
4 token = generate_csrf_token(session)
5 return render_template("email_form.html", csrf_token=token)
6
7@app.route("/account/email", methods=["POST"])
8@login_required
9def change_email():
10 if request.form.get("csrf_token") != session.get("csrf_token"):
11 abort(403)
12 current_user.email = request.form["email"]
13 db.session.commit()
14 return redirect("/account")

Why can't the attacker just read the token off the real page and put it in their form? Same-origin policy. A script running on evil.example can point the victim's browser at bank.example, but it can't read the response, embed it in an iframe and inspect its contents, or otherwise get at the token value. The browser enforces that boundary regardless of what the attacker's page tries.

This is why the token has to be unpredictable, not just present. A hidden field with a constant value, or one derived from something guessable like the user's ID, defeats the entire point, the attacker just puts the same predictable value in their forged form. Use whatever your framework generates for this (Django, Rails, and most others ship one built in) rather than rolling your own.

SameSite cookies, and why a state-changing GET is the real bug underneath

The SameSite cookie attribute tells the browser when it's allowed to attach a cookie to a cross-site request at all, which moves this defense out of your application code and into the browser itself.

A session cookie, set correctly

HTTP
1Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly

Strict never sends the cookie on a cross-site request, full stop, including when the user clicks a link to your own site from an email. That's airtight and also mildly broken UX: the user lands on your site logged out and has to navigate again for the cookie to kick in. Lax is the middle ground and the default in every modern browser: it withholds the cookie from cross-site POSTs, image loads, iframes, and fetch calls, but still sends it on a top-level navigation, clicking an actual link. That default alone would have blocked the auto-submitting form from the first section, since a POST triggered by JavaScript from another origin is exactly what Lax withholds cookies from.

Notice the gap in that sentence: Lax still sends the cookie on a top-level GET navigation. Which means a state-changing action that only checks "is this user logged in" and happens to be wired up behind a GET is still exploitable, cookie policy or not.

A delete link, implemented the way it looks in a hundred admin panels

PythonVulnerable
1@app.route("/posts/<int:post_id>/delete") # GET, no method specified
2@login_required
3def delete_post(post_id):
4 db.query(Post).filter_by(id=post_id).delete()
5 return redirect("/posts")

A malicious page only needs an ordinary link: <a href="https://app.example/posts/42/delete">Click for a free prize</a>. One click, while logged in, and the post is gone, SameSite=Lax and all, because Lax was never designed to stop top-level navigation. The actual bug isn't the missing cookie attribute. It's that a GET request, which the HTTP spec says should be safe to prefetch, retry, and follow without a second thought, is doing something that changes data.

Every state-changing action, delete, update, transfer, belongs behind POST, PUT, PATCH, or DELETE, never GET. Get that right and SameSite=Lax closes the rest of the gap almost by accident.

Layering the defenses, and what JSON APIs get for free

None of the three defenses above is meant to stand alone. CSRF tokens protect form-submitting requests. SameSite protects against browsers that respect it, which is most of them now, but not every client an app might have to support. Correct HTTP methods remove an entire category of exploit outright. Use all three, and a gap in one is covered by the other two.

JSON APIs get a defense almost by accident

A plain HTML <form> can only send a body as application/x-www-form-urlencoded, multipart/form-data, or text/plain. It cannot set Content-Type: application/json without JavaScript, and cross-origin JavaScript that tries to make that request triggers a CORS preflight your server controls the answer to. An endpoint that only accepts JSON is, without anyone specifically designing it that way, already unreachable by a bare auto-submitting form.

Requiring the content type is a real, if incidental, CSRF check

PythonFixed
1@app.route("/api/account/email", methods=["POST"])
2@login_required
3def change_email_api():
4 if request.headers.get("Content-Type") != "application/json":
5 abort(400)
6 current_user.email = request.get_json()["email"]
7 db.session.commit()
8 return {"status": "ok"}

This only holds as long as your CORS configuration doesn't undo it, an Access-Control-Allow-Origin: * paired with credentials enabled hands the whole protection back to any origin that asks. That's its own failure mode, covered in the misconfiguration article later in this series, worth knowing about now so it doesn't undo the work here without you noticing.

For anything that really matters, ask again

Tokens and SameSite stop the forged request from working. For actions where the cost of being wrong is high, wiring transfers, changing the account email, deleting an organization, add a second factor the token model doesn't cover: re-enter your password, confirm a code sent to your existing email, whatever fits the product. It doesn't replace the defenses above. It just accepts that any single check can fail in a way nobody predicted, and makes sure the most damaging actions don't depend on only one.