Guide
Percent-encoding: what to escape in a URL, and where
There is no single answer to "is this character safe in a URL", because a character that is harmless in a query string can split a path in two. Percent-encoding is component-sensitive, and most encoding bugs come from applying one component's rules to another.
The mechanism
Percent-encoding replaces a byte with % followed by its two-digit uppercase hexadecimal value. A space is %20, a slash is %2F, a percent sign is %25.
Note byte, not character. For anything outside ASCII the character is first encoded to bytes — UTF-8, in every modern context — and each byte is escaped separately. So é, which is 0xC3 0xA9 in UTF-8, becomes %C3%A9, and a single emoji becomes four escapes.
é -> %C3%A9 (2 UTF-8 bytes)
€ -> %E2%82%AC (3 bytes)
🙂 -> %F0%9F%99%82 (4 bytes)
RFC 3986 does not mandate UTF-8, which is why very old systems occasionally produce Latin-1 escapes. In practice, assume UTF-8 and be suspicious of anything that decodes to mojibake.
Reserved and unreserved
RFC 3986 splits the ASCII range into three groups.
Unreserved — always safe, never need escaping, and should not be escaped:
A-Z a-z 0-9 - . _ ~
Reserved — these have structural meaning somewhere in a URL. They are split into two sets:
gen-delims: : / ? # [ ] @
sub-delims: ! $ & ' ( ) * + , ; =
Everything else — spaces, ", <, >, \, ^, {, }, |, control characters, and all non-ASCII — must be escaped everywhere.
The subtlety is that reserved characters are only reserved where they mean something. A ? ends the path and begins the query, so it must be escaped inside a path segment — but inside the query itself it is just a character and needs no escaping. That is why there is no one list of "characters to escape in a URL".
The rules, component by component
https://user@host:443/path/segment?key=value#fragment
└─┬─┘ └─┬─┘ └┬─┘ └─────┬─────┘└────┬────┘ └───┬───┘
scheme userinfo host path query fragment
- Host — percent-encoding does not apply to non-ASCII here at all. International domain names use Punycode:
háčky.czbecomesxn--hky-qla6a.cz. Writing%C3%A1in a hostname is simply wrong. - Path segment — escape
/(it would create a segment),?and#(they end the path).&and=are legal and common in a path. - Query value — escape
&and=(they separate pairs),#(it ends the query), and+(see below)./and?are legal and need no escaping, though many encoders escape them anyway. - Fragment — only
#genuinely needs escaping; the fragment runs to the end of the URL.
Over-escaping is legal and safe: %2F in a query decodes to / and no parser objects. Under-escaping is what breaks things. When in doubt, escape more.
One exception to "over-escaping is harmless": %2F inside a path segment. Some servers and proxies — Apache with AllowEncodedSlashes off, and several reverse proxies — reject or silently decode it before routing, so a path segment that legitimately contains a slash can be unroutable no matter how correctly you encode it. Put such values in the query string instead.
encodeURI versus encodeURIComponent
JavaScript gives you two functions, and picking the wrong one is the most common URL bug there is.
encodeURIComponent escapes everything except the unreserved set plus !'()*. Use it for a single piece — one path segment, one query key, one query value.
encodeURI leaves all reserved characters alone, on the assumption you are handing it a complete URL that is already structured. Use it only to clean up a whole URL that contains spaces or non-ASCII.
const value = 'a/b?c=d&e';
encodeURIComponent(value) // 'a%2Fb%3Fc%3Dd%26e' correct for a parameter
encodeURI(value) // 'a/b?c=d&e' unchanged — wrong here
Running encodeURI on a value is how you end up with a parameter whose & splits it into two parameters — the classic injection point for overriding a later value in the query string.
Two footnotes on encodeURIComponent. It does not escape !, ', (, ), or *, which are sub-delims and legal but occasionally unwelcome — OAuth 1.0 signing, for instance, requires them escaped. And it throws a URIError on a lone surrogate, so text sliced mid-emoji fails rather than mangling.
In most modern code you should not be calling either. URL and URLSearchParams handle the structure for you:
const url = new URL('https://example.com/search');
url.searchParams.set('q', 'a/b?c=d&e');
url.searchParams.set('lang', 'čeština');
url.toString();
// https://example.com/search?q=a%2Fb%3Fc%3Dd%26e&lang=%C4%8De%C5%A1tina
The plus sign, and where the myth comes from
Half the internet will tell you + means space in a URL. It is true in exactly one place.
application/x-www-form-urlencoded — the format an HTML form posts, and the format most frameworks assume for a query string — encodes a space as +. That is an HTML specification, not a URL specification. RFC 3986 knows nothing about it; in a path segment or a fragment, + is a literal plus.
/search?q=a+b q = "a b" (form-urlencoded reading)
/search?q=a%20b q = "a b" (always)
/files/a+b.txt filename is literally "a+b.txt"
The practical consequences:
- A literal
+in a query value must be sent as%2B, or it arrives as a space. This is why Base64 in a query string corrupts — the standard alphabet contains+. %20is understood as a space in both readings, so it is the safe choice everywhere.URLSearchParamsemits+;encodeURIComponentemits%20. Both are correct for a query string.- Email addresses with plus-addressing —
user+tag@example.com— break constantly for exactly this reason.
Double encoding
Encoding an already-encoded string escapes its percent signs, and the value now needs two decode passes to come back:
'a b' -> 'a%20b' -> 'a%2520b'
one pass two passes
'%2520' decodes once to '%20' and again to ' '
The tells are %25 in a URL where you did not expect it, and values arriving as literal %20 text. The cause is almost always a value passing through two layers that each encode — a template that escapes, then a client library that escapes again — or a redirect URL that was encoded when it was put in a parameter and encoded again when the response was built.
The rule that prevents it: encode exactly once, at the point where you assemble the URL, and store and pass values around in their decoded form everywhere else. A variable holding an encoded string should not travel far from the concatenation it was made for.
Decoding, and the errors to expect
decodeURIComponentthrows aURIErroron a malformed sequence — a stray%, or%followed by non-hex. User-supplied URLs will hit this, so wrap it.- It also throws on percent-escapes that do not form valid UTF-8, which is what you get from a Latin-1 encoder.
- Decode after splitting on structural characters, never before. Decoding first turns an escaped
%26into a real&, and the split then produces a parameter the sender never wrote — a straightforward injection. - Do not decode a whole URL and then parse it. Parse the structure, then decode each piece.
That third point is the security-relevant one, and it generalises: percent-decoding is the last step, after every structural decision has already been made on the encoded form. Path traversal filters that check for ../ before decoding, and are handed %2e%2e%2f, are the canonical version of getting this backwards.
Last updated 10 August 2026