URL Encoding: %20, Plus Signs, and Double Encoding
Published 2026-10-31
Only a limited set of characters can appear literally in a URL. A space, a Chinese character, an & or a # will either break the URL or be reinterpreted as something else entirely.
The rule itself is trivially simple: encode the character to UTF-8 bytes, then write each byte as % plus two hex digits. The Chinese character 中 is E4 B8 AD, so it becomes %E4%B8%AD.
Everything difficult lives in the exceptions.
What actually has to be encoded
RFC 3986 splits characters into three groups:
| Group | Characters | Treatment |
|---|---|---|
| Unreserved | A-Z a-z 0-9 - . _ ~ |
Never encode |
| Reserved | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
Depends on position — do not encode when used as a delimiter |
| Everything else | space, quotes, < > \ ^ { ` |
}`, non-ASCII |
"Depends on position" is the first trap. / is a path separator. Encoding it as %2F tells the server "this is a single path segment literally named a/b" — some servers return 404, some reject it with 400, and a few will happily treat it as a file path and hand you a directory traversal.
Space: %20 or +
Both are legal, but they do not mean the same thing:
%20always means "there is a space here"+means "space" only inside a query string or a form body, perapplication/x-www-form-urlencoded
In a path like /a+b/c, the + is a literal plus sign. That mismatch produces the classic bug where an image URL silently 404s while looking perfectly normal:
| Position | Write a space as | Note |
|---|---|---|
| Path | %20 |
+ stays a plus |
| Query string | %20 or + |
Equivalent |
| Form POST body | + |
Standard, though %20 is accepted |
encodeURI vs encodeURIComponent
These two JavaScript functions behave completely differently, and mixing them up is the single most common URL bug:
encodeURI('https://a.com/search?q=hello world')
// https://a.com/search?q=hello%20world
// : / ? = are preserved — assumes you are encoding a whole URL
encodeURIComponent('https://a.com/search?q=hello world')
// https%3A%2F%2Fa.com%2Fsearch%3Fq%3Dhello%20world
// Everything encoded — assumes you are encoding a single value
The decision is always the same question: are you encoding a URL, or a piece of one?
// Wrong: & inside the keyword is not escaped
`https://api.com/search?q=${encodeURI(keyword)}`
// Right
`https://api.com/search?q=${encodeURIComponent(keyword)}`
With keyword = 'a&b', the first version sends ?q=a&b — two parameters, one of them empty.
Double encoding: looks identical, server disagrees
Encoding twice is a common over-correction. The % itself gets encoded to %25, so 中 becomes %25E4%25B8%25AD.
The symptom is distinctive: %25 in the address bar. If you see it, something encoded twice.
The other source is a framework doing it for you. You encode a value in application code, then the HTTP client encodes the whole URL again because it assumes it received a raw string. The reliable debug step is to print the final URL that goes on the wire and look for %25.
Why non-ASCII text becomes several percent signs
In UTF-8, common CJK characters take 3 bytes, so you get three %XX groups. Rarer characters (outside the Basic Multilingual Plane) take 4, giving four groups.
That also explains an old mystery: the same text encoded on a GBK page produces 2 groups per character, while UTF-8 produces 3. The encoding result depends on the character set. When two systems disagree on a signature, confirm both sides are on UTF-8 before looking anywhere else.
Signature mismatch is almost always an encoding disagreement
Payment gateways and open platforms love to return "signature error" for requests where every visible parameter matches.
The failure is usually in the canonical string, not the transport:
| Step | Typical divergence |
|---|---|
| Which fields are signed | Empty values included or excluded? Is sign itself excluded? |
| Sorting | Dictionary order by key, or the order parameters were added |
| Encoding | Encoded before signing, then encoded again on send |
| Space | %20 on one side, + on the other |
| Case | %2f vs %2F — most platforms require uppercase |
The fastest fix is to log both the exact string you signed and the exact URL you sent, then diff them character by character.
When Base64 beats URL encoding
If you need to stuff arbitrary data into a URL and do not want to fight the URL rules (a token containing &, =, or non-ASCII), Base64URL is the better carrier: it replaces + and / with - and _, drops the = padding, and produces a string that is always URL-safe.
The conceptual split: URL encoding answers "may this character appear in a URL", Base64 answers "how do I turn binary into plain text". JWT uses Base64URL for exactly this reason — see Base64 Is Not Encryption and What Is a JWT.
Keep the three rules
- Encode a value with
encodeURIComponent; encode a whole URL withencodeURI. - In a path, write spaces as
%20, never+. %25in the address bar means double encoding.
You can check any string in URL Encoder — it shows the byte-level result, which is what you actually need when a gateway insists your signature is wrong.