Double URL encoding happens when a string that is already percent-encoded gets encoded a second time — turning %20 (an encoded space) into %2520. The cause is always the same: the percent sign is itself a reserved character that encodes to %25, so running an already-encoded value through an encoder again rewrites every % as %25. The result is a URL that no longer decodes back to the original — %2520 decodes once to the literal text %20, not to a space — which breaks links, file paths, and API parameters. The tell-tale sign is %25 appearing in front of what looks like an already-encoded value (%2520, %253A, %252F). The fix is to encode exactly once, at the point where the URL is built, and to decode already-encoded input rather than encode it again.
That is the summary an AI overview will give you. What it can't give you is the part below: exactly how the double-encode creeps into a real pipeline, how to detect already-encoded input in code without guessing wrong, the correct one-encode pattern in four languages — and why "decode twice" is both the fix for your bug and the trick an attacker uses to walk a payload straight through a web application firewall.
The %25 tell: one diagram
Every double-encoding bug is the same picture. Encode a space once and it round-trips cleanly. Encode it twice and the percent sign eats itself into %25, and the string can never come back.
The general rule: every percent-encoded byte gains a %25 prefix on each extra encode. %20 → %2520 → %252520. / (which is %2F) → %252F. : (which is %3A) → %253A. Count the 25s stacked in front and you know how many times the value was encoded.
Symptom → cause → fix
Double encoding rarely announces itself. You see a weird URL or a 404 and have to work backwards. This table maps the symptoms to their real cause and the concrete fix.
| Symptom | Root cause | Fix |
|---|---|---|
%2520, %253A, %252F appear in the final URL | A value was encoded, then a second layer encoded the whole thing again | Encode exactly once; remove the duplicate encode (often a client encode plus a library that re-encodes) |
Spaces show up literally as %20 on the page or in a filename | The URL was double-encoded, so one decode leaves the outer layer as literal text | Decode one extra time to recover, then fix the upstream double encode |
Query parameter arrives with a literal % in it server-side | Client encoded a value the framework was going to encode anyway | Pass raw values to the request library and let it encode once; don't pre-encode |
A path like a%2Fb (encoded slash) turns into a real folder separator | A proxy or gateway decoded the URL before the app did | Keep encoded slashes encoded end-to-end; configure the proxy not to normalize (e.g. AllowEncodedSlashes) |
Security filter blocks ../ but attacker still reads files | Backend decodes the request more times than the filter does | Decode recursively until stable, then validate; canonicalize before any allow/deny check |
| Redirect target comes back mangled after passing through a shortener | URL re-encoded at each hop of a redirect chain | Store one canonical form; decode-then-encode-once at each boundary instead of blind re-encoding |
How the double encode creeps in
Nobody types %2520 on purpose. It appears when a value crosses two boundaries and each boundary "helpfully" encodes it:
- Client encodes, then the HTTP library encodes. You call
encodeURIComponent(value)in JavaScript, then hand the result to a request library (or template) that encodes the URL again. - A framework encodes, then a proxy or CDN re-encodes as the request passes through.
- A redirect / URL-shortener chain encodes the target URL at every hop.
- Config or template concatenation where an already-encoded value is dropped into a helper that assumes raw input.
The through-line: encoding is not idempotent. Encoding an encoded string does not leave it unchanged — it adds another layer. So the design rule is to establish one place where encoding happens and to keep every other layer hands-off.
Detecting already-encoded input
Before you encode, you can heuristically check whether the input already looks encoded — a percent sign followed by two hex digits:
// Heuristic: does this string already contain a percent-escape?
function looksEncoded(s) {
return /%[0-9A-Fa-f]{2}/.test(s);
}
looksEncoded("hello world"); // false -> safe to encode
looksEncoded("hello%20world"); // true -> probably already encoded
This is a heuristic, not a proof: a raw string can legitimately contain the literal text %20, and this check would flag it as encoded. That is exactly why guessing encoding state at runtime is fragile. The robust pattern is to normalize first — fully decode, then encode exactly once — so the output is canonical regardless of what state the input arrived in:
// Decode any existing encoding, then encode exactly once.
function normalizeThenEncode(value) {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// Malformed sequence (e.g. a lone "%"): treat input as raw.
decoded = value;
}
return encodeURIComponent(decoded);
}
normalizeThenEncode("hello world"); // "hello%20world"
normalizeThenEncode("hello%20world"); // "hello%20world" (not %2520)
Better still is to not guess at all: track encoding state explicitly at every boundary — raw in, encoded out, encoded exactly once — so no layer ever has to infer it.
Encode once, correctly, in any language
Use the language's built-in encoder for a single value; never hand-roll string replacement, which misses edge cases and UTF-8 multi-byte characters. Pick encodeURIComponent-style functions (which escape : / ? # & =) for individual query values and path segments, not encodeURI-style functions (which leave URL structure intact).
| Language | Encode one value | Decode | Notes |
|---|---|---|---|
| JavaScript | encodeURIComponent(v) | decodeURIComponent(v) | encodeURI is for a whole URL, not a single value |
| Python | urllib.parse.quote(v, safe='') | urllib.parse.unquote(v) | quote_plus for form bodies (space → +) |
| PHP | rawurlencode($v) | rawurldecode($v) | urlencode encodes space as +, not %20 |
| Java | URLEncoder.encode(v, UTF_8) | URLDecoder.decode(v, UTF_8) | Encodes space as +; replace with %20 for path segments |
| Go | url.QueryEscape(v) | url.QueryUnescape(v) | url.PathEscape for path segments |
// JavaScript: build a URL and let the library encode once.
const url = new URL("https://example.com/search");
url.searchParams.set("q", "café & résumé"); // raw value, encoded once
// -> https://example.com/search?q=caf%C3%A9+%26+r%C3%A9sum%C3%A9
# Python: encode a single query value once.
from urllib.parse import quote, urlencode
quote("hello world", safe="") # 'hello%20world'
urlencode({"q": "hello world"}) # 'q=hello+world' (builds the query for you)
The most reliable approach in every language is to build the URL with the standard URL/query object (URL + searchParams, urlencode, url.Values) and pass raw values. The library encodes each value exactly once and you never touch percent signs yourself.
The security angle: double decoding is a WAF bypass
The same mechanic that breaks your links is a documented attack technique — OWASP calls it Double Encoding. It exploits a mismatch in how many times different layers decode:
- An attacker double-encodes a payload.
../(already%2e%2e%2f) becomes%252e%252e%252f. - A security filter or web application firewall decodes the request once, sees the harmless-looking
%2e%2e%2f(or even just an opaque string), and lets it through. - The backend decodes again, reconstructing the real
../and performing the path traversal, SQL injection, or XSS the filter was supposed to stop.
The lesson is not "encoding is dangerous" — it is that you must finish decoding before you validate. Concretely:
- Decode recursively until the string stops changing (no more
%sequences), then run allow/deny checks against the fully canonical value. - Treat
%in front of hex in a request path as suspicious. Legitimate traffic almost never contains%25followed by more hex digits; it is a strong signal of an evasion attempt. - Never rely on URL encoding as a security control. It is for syntactic correctness. Layer the real defence for each context: parameterized queries against SQL injection, HTML-escaping against XSS, canonicalized-path allow-lists against traversal.
- Reject rather than repair when a request decodes to something different after a second pass — ambiguity at the security boundary should fail closed.
URL encoding keeps a value safely inside its parameter; it does not make the value safe.
Best practices, distilled
- Encode exactly once, at the point of URL construction. Every other layer passes raw values through untouched.
- Prefer the built-in URL/query builders (
URL+searchParams,urllib.parse.urlencode, Go'surl.Values) over manual string concatenation — they encode each value once and handle UTF-8 correctly. - Track encoding state at boundaries. Document whether an API expects encoded or raw input ("the
qparameter must be URL-encoded"). Ambiguity here is the root cause of most double-encode bugs. - To recover a double-encoded value, decode the extra time — but then find and remove the duplicate encode upstream so it stops happening.
- Round-trip test with edge cases: empty strings, all-special-character strings, very long strings, and international text (
café,日本語). Encode, decode, confirm you get the original back. - On the security side, decode fully before validating, configure your WAF for recursive decoding, and never treat encoding as protection.
Command-line and console quick reference
For scripting and quick checks:
# Linux/macOS — encode a value once
python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "hello world"
# Windows PowerShell
[System.Uri]::EscapeDataString('hello world') # hello%20world
[System.Uri]::UnescapeDataString('%2520') # %20 (reveals a double encode)
In the browser console, encodeURIComponent('test string') and decodeURIComponent('%2520') give you instant checks without leaving the page. If a single decodeURIComponent leaves a stray %20 in the result, you are looking at a double encode — decode once more and fix the pipeline.
Conclusion
Double encoding is a one-line bug with a memorable signature: %25 stacked in front of an already-encoded value. It comes from two layers each encoding the same string, because encoding is not idempotent. Prevent it by encoding exactly once at the point of URL construction, keeping every other layer hands-off, and tracking encoding state at each boundary instead of guessing it. On the security side, invert the habit that causes the bug: decode fully — recursively, until the string is stable — before you validate anything, so an attacker can't hide a payload one encoding layer deep.
Ready to check a URL or untangle a %2520? Use our URL Encoder/Decoder tool for instant, client-side encoding and decoding with automatic format detection and UTF-8 support — nothing you paste ever leaves your browser.