How HTML forms work: from POST to CSRF
A form is the browser's built-in request builder — and understanding what it builds explains GET vs POST, why refreshing re-orders your momos, and how a page you never saw can act as you. Three simulators build a raw POST from form fields, race the naive flow against POST-redirect-GET, and run a CSRF attack with and without a token.
mindmap — quick refresh
Prerequisites: How HTTP works — methods, headers, and cookies — and the session idea from Authentication vs authorization. The SSR labs help but aren't required.
Everything you've built so far only reads: every request in the SSR labs was a GET with, at most, a query string. But the browser has exactly one built-in way for a user to send data — the HTML form — and it's older than JavaScript. Two facts drive this whole guide: submitting a form makes the browser construct an HTTP request from your input fields, and the browser attaches your cookies to every request to a site, no matter which page started that request. The first fact explains GET vs POST and the duplicate-order bug. The second is the entire reason CSRF attacks exist.
A form is a request builder
Here's a form for our food-delivery app. The load-bearing detail is the name attribute on each field — those become the keys on the wire:
<form action="/order/" method="post">
<label>your name <input name="customer" value="Asha Rai"></label>
<label>item <input name="item" value="momo"></label>
<label>qty <input name="qty" type="number" value="2"></label>
<button>place order</button>
</form>
Click the button and the browser builds a request: the pairs are encoded as application/x-www-form-urlencoded — customer=Asha+Rai&item=momo&qty=2, spaces becoming +, & separating pairs (the same encoding as a query string) — and where that data travels depends on method. Watch the request get assembled, then flip the method and see the data move:
The GET/POST split is a meaning split, not just a location split. GET says "read something": data rides in the URL, so results are bookmarkable, shareable, and safe to repeat — right for search and filters. POST says "change something": data rides in the body, isn't logged in URLs or history, and is not safe to repeat — right for placing orders, registering, logging in. Every framework, proxy, and browser feature leans on this contract, which is exactly why the next bug exists.
What the server does — and the refresh bug
On the server (recall the WSGI environ), a POST body waits in wsgi.input; parsing customer=Asha+Rai&item=momo is urllib.parse.parse_qs away. Parse, validate, insert the order, and respond with a nice "order placed!" page. Done?
Not quite. Refresh re-sends the last request. If the last request was that POST, refreshing places the order again — the browser even warns you with that "resubmit form data?" dialog people click through. The fix is a pattern old enough to have furniture: POST-redirect-GET (PRG). Never answer a successful POST with a page. Answer with 303 See Other and a Location; the browser immediately GETs that URL, and now the "last request" is a harmless GET that can be refreshed forever:
This is why every well-built site bounces you to a fresh URL after you submit — and why Django views end with return redirect(...) after a successful POST.
CSRF: the form your user never saw
Now the security consequence of the browser's helpfulness. Asha is logged into food.example — her session cookie is stored. Cookies attach by destination domain: any request going to food.example carries her cookie, even if the page that triggered the request is somewhere else entirely. So an attacker's page can contain a hidden form pointed at food.example that submits itself via JavaScript the moment the page loads. The browser dutifully attaches Asha's cookie. The server sees a valid session and a well-formed order. That's Cross-Site Request Forgery — the user's browser is turned against them:
The defense exploits the one thing the attacker doesn't have: the ability to read your site. The CSRF token is a random value the server plants in every real form (a hidden field) and ties to the session; on every POST it checks that the two agree. The attacker's page can send requests to food.example, but the same-origin policy stops it from reading food.example's pages — so it can never learn the token, and its forged POST fails with 403. In short: the cookie proves it's Asha's browser; the token proves the form came from us. Modern sites layer SameSite cookie rules on top, but the token remains the workhorse — and note the quiet corollary: this is also why state-changing actions must never ride on GET, since a bare <img src="https://food.example/order/?item=momo"> on any page would fire one.
The same machine in Django
Map what you now know onto what Django ships and its forms machinery stops being ritual:
| This guide | Django |
|---|---|
| parsing the urlencoded body | request.POST (a ready-parsed dict) |
| validate each field, re-show on error | a forms.Form class with is_valid() |
| PRG after success | return redirect("order-detail", oid) |
| the hidden token field | the csrf_token tag in the template |
| checking token vs session on every POST | CsrfViewMiddleware — rejecting in the middleware onion before your view runs |
Nothing in that right column is magic anymore: each row is a mechanism you've now watched happen on the wire.
Takeaways
- A form builds an HTTP request from its
named fields —application/x-www-form-urlencodedpairs, in the URL for GET, in the body for POST. - GET reads, POST writes — the split is semantic, and browsers/proxies/frameworks all rely on it; state-changing actions must never ride on GET.
- Answer a successful POST with a redirect, never a page — POST-redirect-GET makes refresh harmless; a 200 answer to POST is a duplicate-order bug waiting.
- Cookies attach by destination, not by who asked — that convenience is the entire attack surface of CSRF.
- CSRF defense = a secret the attacker can't read — token in the form, tied to the session, checked on every POST; Django's
csrf_tokentag and middleware are exactly this.
References
- Django Software Foundation. (n.d.). Cross Site Request Forgery protection (Django documentation). Retrieved August 17, 2026, from https://docs.djangoproject.com/en/stable/ref/csrf/
- Fielding, R., Nottingham, M., & Reschke, J. (2022). HTTP semantics (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110
- MDN Web Docs. (n.d.). Sending form data. Mozilla. Retrieved August 17, 2026, from https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Sending_and_retrieving_form_data
- OWASP Foundation. (n.d.). Cross-site request forgery prevention cheat sheet. Retrieved August 17, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html