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:

  • %20 always means "there is a space here"
  • + means "space" only inside a query string or a form body, per application/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

  1. Encode a value with encodeURIComponent; encode a whole URL with encodeURI.
  2. In a path, write spaces as %20, never +.
  3. %25 in 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.