Double Encoding: When %2520 Isn't a Bug
Every article about URL bugs lists double encoding as mistake number one, and usually that's fair. But I've now hit three production systems where an extra encoding layer is correct by specification, and "fixing" it is the actual bug. This post is about telling the difference. Everything below was run for real: I spun up nginx and Apache locally, and verified every string with Node.
The Mechanics: One Decode per Consumer
Percent-decoding is defined as a single pass. A decoder receives a string, replaces each %XX with its byte, and stops. Here is the layer chain I generated:
| Layers | String | One decode returns |
|---|---|---|
| 0 | Hello World | — |
| 1 | Hello%20World | Hello World |
| 2 | Hello%2520World | Hello%20World |
| 3 | Hello%252520World | Hello%2520World |
decodeURIComponent("%2520") returns "%20" — verified. Nobody "decodes until it looks clean," and that is precisely why the common advice of "decode repeatedly until stable" destroys real data.
The Decode-Until-Stable Trap
I ran this demonstration:
const value = "50%20off"; // a literal percent in the data
const encoded = encodeURIComponent(value); // "50%2520off"
let current = encoded;
for (let i = 0; i < 3 && current.includes("%"); i++) {
current = decodeURIComponent(current); // 1st pass: "50%20off"
} // 2nd pass: "50 off"
console.log(current); // "50 off" — data destroyed
I ran this loop and the result is "50 off". The multi-pass "convenience" turned a legitimate %20 in the data into a space. Single-pass decoding returns the original value; the loop corrupts it.
Legitimate Case 1: Signed Canonical Requests (SigV4)
AWS Signature Version 4 computes the signature over a canonical request, and the canonical URI is percent-encoded — with the requirement that an already-encoded URI is encoded a second time. That is why the doubleEncode flag in the AWS SDKs defaults to true. Simplified to a single S3 object key:
const key = "my file.txt"; // S3 object key
const once = encodeURIComponent(key); // "my%20file.txt"
const canonical = encodeURIComponent(once); // "my%2520file.txt"
console.log(once, "->", canonical); // my%20file.txt -> my%2520file.txt
I ran this and got my%20file.txt -> my%2520file.txt. The reason this is by design, not a bug: the signature must be stable no matter how the request travels. If the signer computes over one layer and a client library or proxy adds or strips a layer in transit, the signature no longer matches and the request fails with a SignatureDoesNotMatch error. The double encoding makes the canonical form explicit. (The full SigV4 spec encodes each path segment individually and keeps the / separators unencoded — the two-pass rule is the part everyone misses.)
Legitimate Case 2: Proxies That Eat Exactly One Layer
For this test I ran nginx 1.31.3 in front of a small Express echo server. The nginx config included:
location /proxy2 {
proxy_pass http://127.0.0.1:17999/files;
}
Then I sent requests with curl --path-as-is and read what the Express backend received:
What Arrived at the Backend
| Request to nginx | What Express received |
|---|---|
/proxy2/a%2Fb | /files/a/b — the encoded slash was decoded in transit |
/proxy2/a%252Fb | /files/a%252Fb — one layer consumed, one layer intact |
/proxy2/a%2520b | /files/a%2520b — same pattern for the space |
The pattern is consistent: this proxy hop normalizes the URI, which peels exactly one encoding layer and re-encodes the % it leaves behind. Send one layer, it arrives raw; send two layers, one survives. A client that needs the backend to receive a literal %2F as text has to send %252F. For comparison, when proxy_pass had no URI part (proxy_pass http://127.0.0.1:17999;), the client's raw request line was forwarded verbatim — I confirmed /proxy/files/a%2Fb arrived untouched. The layer math depends on the hop, and it's deterministic, not random.
Apache adds its own twist. Its default AllowEncodedSlashes Off rejects any path containing %2F with a 404 — I verified this on Apache 2.4.62 — but the same server happily serves %252F, handing the CGI one layer: PATH_INFO=/a%2Fb. So on a default Apache, double encoding is the only way to pass a slash that you don't want treated as a separator.
The point most tutorials never mention: routing and forwarding disagree about the same request. nginx matches locations against the decoded URI — my request /static/a%2Fb matched the exact location /static/a/b — while the access log and $request_uri keep the raw form. One request, two different "paths" depending on which layer of the stack you ask.
Legitimate Case 3: Log Pipelines
A CDN logs the raw request URI. A log shipper forwards each line through a query-string endpoint, which percent-encodes the line's own % characters. The stored line looks double-encoded, and that's correct. I reproduced the full round trip:
const url = "/files/a%20b"; // what the CDN saw
const line = `GET ${url} 200`;
const shipped = new URLSearchParams({ line }).toString();
console.log(shipped); // line=GET+%2Ffiles%2Fa%2520b+200
console.log(new URLSearchParams(shipped).get("line")); // GET /files/a%20b 200
I ran this and the recovered line is GET /files/a%20b 200 — the original, single-encoded URL. One decode on read restores it exactly. The damage usually comes from hand-rolled shippers that concatenate lines without encoding, or from readers who "helpfully" decode twice.
Counting Layers in a Live Request
When you're handed a %2520 in a bug report, the useful question isn't "is this wrong?" but "how many layers were intended?" I decode one layer at a time and print each intermediate form:
let s = "Hello%252520World"; // what arrived at the API
console.log(s); // Hello%252520World
s = decodeURIComponent(s);
console.log(s); // Hello%2520World
s = decodeURIComponent(s);
console.log(s); // Hello%20World
I ran this and got the three lines shown. Each line is a snapshot of what one hop in the pipeline "saw" — client, proxy, backend. The mismatch between the intended layer count and the observed one tells you which hop double-encoded or under-encoded. In the three legitimate cases above, the intended count is spelled out by a spec or a hop you can inspect; in the bug case, it usually isn't.
So: Bug or By Design?
Here's the decision table I use in code review:
| Situation | Verdict | Action |
|---|---|---|
| Same encoder applied twice by accident | Bug | Strip one layer |
| SigV4 (or similar) canonical form | By design | Match the spec's layer count, or signatures fail |
A normalizing hop (nginx proxy_pass with URI, Apache with AllowEncodedSlashes On) | By design | Send one layer per normalizing hop; decode once at the consumer |
| A storage layer re-encodes for transport (logs, audit events) | By design | Decode exactly once on read |
One more verified data point from the Express side: frameworks apply the same single-pass rule. A request for /files/a%252Fb arrived in my route handler with req.url still raw (/files/a%252Fb) but req.params.name decoded exactly once, as "a%2Fb". If you're debugging a pipeline and suspect over-encoding, our URL Decoder peels the layers for you and shows each intermediate form — handy for tracing. For signature-sensitive work, decode one layer at a time yourself so you can see exactly what each hop actually received.
The golden rule that survives all three cases: encode at every boundary, decode exactly once at consumption, and never "fix" a layer count without knowing which hop owns it. A %2520 is only a bug when nobody in the pipeline intended it. When a signer, a proxy, or a logger did, deleting that layer is what breaks the pipeline.
Try Our Tools
- URL Decoder — decode layer by layer and see the intermediate forms
Related reading:
- URL Decode vs Encode: Understanding the Difference
- Common URL Decoding Errors (and How to Avoid Them)
— The URL Decode Online Team