URL encoding, decoded
A percent sign and two hex digits. Conceptually the simplest encoding there is, and responsible for a remarkable share of filter bypasses.
Percent-encoding writes a byte as % followed by its two hex digits. It
exists because a URL has structure — ? starts the query,
& separates parameters, # starts the fragment — and any
value carried inside that structure must not be able to change it. So the value's own
& becomes %26, and the structure survives.
What has to be encoded, and where
| Set | Characters | Behaviour |
|---|---|---|
| Unreserved | A–Z a–z 0–9 - _ . ~ | Never need encoding, and should not be encoded — %41 and A are the same character, which is itself a normalisation problem. |
| Reserved | : / ? # [ ] @ ! $ & ' ( ) * + , ; = | Structural. Encode them when they are data, leave them when they are structure. |
| Everything else | spaces, non-ASCII, control bytes | Always encoded. Non-ASCII is encoded as its UTF-8 bytes: é is %C3%A9, two bytes, not one. |
The + problem
In a query string or a form-encoded body, + means a space. In a path
segment it means a literal plus. One string, two rules, depending on which part of the
URL it sits in — which is why decoding a whole URL in a single pass is wrong, and why
the decoder splits a URL into its components and decodes each one
on its own terms.
Double encoding
Encode the percent sign itself and you get a second layer: . is
%2e, and %2e is %252e. Now consider a request
passing through two components that each decode once — a proxy and an application, or a
framework and a file handler:
| Stage | Value | Filter looking for ../ |
|---|---|---|
| As sent | %252e%252e%252f | no match |
| After the first decode | %2e%2e%2f | no match — and this is where the check usually runs |
| After the second decode | ../ | too late |
The bug is not the encoding. It is having more than one decode step with the validation
sitting in the middle. Decode fully, exactly once, then validate — and reject input
that still contains a % after the decode you expected to be the last one.
The decoder here flags a %25 for exactly this reason.
Where it turns up in findings
- Path traversal.
%2e%2e%2f,%2e%2e/, and the backslash variants on Windows. Also%c0%aeand other overlong UTF-8 forms against decoders that accept them. - XSS filter evasion. Percent-encoding layered with HTML entities and
\uescapes, each one decoded by a different stage of the page. The multi-tool unwraps all of them in sequence because that is how they were built. - Open redirect and SSRF. A
next=orurl=parameter carrying an encoded URL. Prefix matching on the encoded form is not validation —https://trusted.example%2f@evil.example/and its relatives depend on exactly this. - Request smuggling and header injection.
%0d%0a— a carriage return and a line feed — reflected into a header value. - Binary payloads. Each
%XXis a byte, so a whole gzip stream can travel percent-encoded. A decoder that goes through a string function instead of working on bytes will throw on it and tell you nothing.
Encoding it correctly
- Encode components, never whole URLs. In JavaScript that is
encodeURIComponent, notencodeURI. - Build URLs with a URL library, so the structure comes from code and the values are only ever values.
- Decode once, at the boundary, and validate what you got — not what arrived.
- Percent-encoding is not output escaping. A value going into HTML needs HTML escaping as well, and into a database, a parameterised query.