Form encoding vs percent-encoding
urlencoded and multipart bodies, when a plus sign means a space, how servers parse each content type, and the double-decoding bugs that hide in request handling.
application/x-www-form-urlencoded
A form submission is not the same as a URL. The body of a urlencoded request applies the percent-encoding rules with one historical twist: a space becomes +, not %20.
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded
q=rock+%26+roll&page=2&tag=a%2Fb
q = "rock & roll" (+ means space, %26 is a literal ampersand)
page = "2"
tag = "a/b" (%2F is an escaped slash, so it is not a path separator)- Every key and value is encoded independently, then joined with
&. +means a space in a form body. In a URL path it means a literal plus.- There is no way to express a repeated key distinctly from an array; servers guess from the language conventions.
- The format has no length prefix, so parsing must scan for the delimiters — escape them correctly or the body is ambiguous.
multipart/form-data
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X8f2a
------X8f2a
Content-Disposition: form-data; name="title"
Quarterly report
------X8f2a
Content-Disposition: form-data; name="file"; filename="data.csv"
Content-Type: text/csv
id,amount
1,19.99
------X8f2a--| Aspect | urlencoded | multipart |
|---|---|---|
| Binary safe | No — percent-encoding inflates by up to 3x | Yes — raw bytes per part |
| Overhead | Small | Boundary plus per-part headers |
| Use for | Short text fields | File uploads, mixed content |
| Encoding of text parts | Percent-encoding | Declared by each part's charset |
| Streaming parse | Easy | Possible, but boundary scanning is stateful |
The boundary must not appear in any part's content. A random boundary long enough to be unique makes that practically certain; a fixed boundary like ---- can be forged by a crafted upload.
The decoding bugs that reach production
- Double decoding — a proxy decodes once, the framework decodes again, and
%2520collapses into a space. - Plus sign loss — decoding a path with a form decoder turns legitimate plus signs into spaces.
- Charset confusion — the body is declared UTF-8 but a legacy client sends Windows-1252, and non-ASCII names become mojibake.
- Key collision — a parameter named
a[b]and one namedamay both map to the same parsed field.
from urllib.parse import parse_qs, unquote, quote
body = "q=rock+%26+roll&tag=a%2Fb"
params = parse_qs(body, keep_blank_values=True)
# {'q': ['rock & roll'], 'tag': ['a/b']} plus and %26 both resolved
unquote("a+b") # 'a+b' unquote does NOT treat plus as space
unquote_plus("a+b") # 'a b' the form rule💡
Decode exactly once, as close to the edge as possible, and pass structured data inward. Every extra layer that decodes is a chance to decode twice, and double decoding is the root cause of a large share of injection vulnerabilities.
FAQ
When does plus mean a space?
In
application/x-www-form-urlencoded bodies and in query strings parsed with form rules. In a URL path, and under strict RFC 3986 handling of a query, plus is a literal plus.Why does my file upload arrive as text?
The request was sent as urlencoded instead of multipart, so the file bytes were percent-encoded. Set the content type and let the HTTP client build the multipart body.
Related
HTML entities and escaping in markup Mojibake: diagnosing and fixing broken text
Last refreshed 2026-09-18.