// web · forms

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 Forms — the browser's request builder form → request name attributes become the wire keys body: application/x-www-form-urlencoded, & pairs, space → + GET = data in URL (read), POST = data in body (write) what the server does parse body → validate → act → respond refresh re-sends the LAST request → duplicate POST POST-redirect-GET: answer 303, browser GETs → refresh safe CSRF cookies auto-attach by domain — no matter who started the request evil page auto-submits a form AS the logged-in user token in the form + session check = proof the form was ours same-origin policy keeps the attacker from reading the token the same machine in Django forms.py validates, redirect() is PRG csrf_token tag plants the hidden field CsrfViewMiddleware rejects in the onion, before the view

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-urlencodedcustomer=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 guideDjango
parsing the urlencoded bodyrequest.POST (a ready-parsed dict)
validate each field, re-show on errora forms.Form class with is_valid()
PRG after successreturn redirect("order-detail", oid)
the hidden token fieldthe csrf_token tag in the template
checking token vs session on every POSTCsrfViewMiddleware — 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 fieldsapplication/x-www-form-urlencoded pairs, 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_token tag 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