Developer Tools

URL Encoding: Already Encoded Detection and Double Encoding

Double encoding happens when you percent-encode a string that was already encoded, turning %20 into %2520. Learn to spot the %25 tell, detect already-encoded input, decode once (not twice), and why double-decoding is a classic WAF bypass.

By Inventive HQ Team

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.

Single encoding versus double encoding of a space Encoding a space once produces %20, which decodes cleanly back to a space. Encoding the already-encoded %20 a second time turns the percent sign into %25, producing %2520, which decodes once only to the literal text %20 instead of a space. Encode once, or corrupt the string forever

ENCODE ONCE — round-trips cleanly hello world encode hello%20world decode hello world ✓

ENCODE AGAIN — the % becomes %25 hello%20world encode again hello%2520world decode once hello%20world ✗

The second encode rewrites the % as %25. Now one decode only unwraps the outer layer — you get the literal text %20, never a space. Rule of thumb: if you see %25 in front of two hex digits, something encoded twice.

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.

SymptomRoot causeFix
%2520, %253A, %252F appear in the final URLA value was encoded, then a second layer encoded the whole thing againEncode 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 filenameThe URL was double-encoded, so one decode leaves the outer layer as literal textDecode one extra time to recover, then fix the upstream double encode
Query parameter arrives with a literal % in it server-sideClient encoded a value the framework was going to encode anywayPass 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 separatorA proxy or gateway decoded the URL before the app didKeep encoded slashes encoded end-to-end; configure the proxy not to normalize (e.g. AllowEncodedSlashes)
Security filter blocks ../ but attacker still reads filesBackend decodes the request more times than the filter doesDecode recursively until stable, then validate; canonicalize before any allow/deny check
Redirect target comes back mangled after passing through a shortenerURL re-encoded at each hop of a redirect chainStore one canonical form; decode-then-encode-once at each boundary instead of blind re-encoding
Advertisement

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).

LanguageEncode one valueDecodeNotes
JavaScriptencodeURIComponent(v)decodeURIComponent(v)encodeURI is for a whole URL, not a single value
Pythonurllib.parse.quote(v, safe='')urllib.parse.unquote(v)quote_plus for form bodies (space → +)
PHPrawurlencode($v)rawurldecode($v)urlencode encodes space as +, not %20
JavaURLEncoder.encode(v, UTF_8)URLDecoder.decode(v, UTF_8)Encodes space as +; replace with %20 for path segments
Gourl.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:

  1. An attacker double-encodes a payload. ../ (already %2e%2e%2f) becomes %252e%252e%252f.
  2. 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.
  3. 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 %25 followed 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.

Loading interactive tool...

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's url.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 q parameter 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.

Frequently Asked Questions

What is double URL encoding?

Double URL encoding is what you get when you percent-encode a string that was already percent-encoded. The first pass turns a space into %20; the second pass sees the literal percent sign and encodes it as %25, so %20 becomes %2520. Decoding that once gives you back the literal text "%20" instead of a space, so the URL is broken. It is almost always a bug caused by two layers of code each encoding the same value.

Why does %20 become %2520?

Because the percent sign is itself a special character that gets encoded as %25. A space encodes to %20. If that %20 is fed through an encoder a second time, the "%" is treated as data and rewritten to "%25", while the "20" is left alone — producing %2520. The same thing happens to every encoded byte: %3D becomes %253D, %2F becomes %252F. Seeing %25 in front of what looks like an encoded value is the signature of double encoding.

How do I detect if a string is already URL-encoded?

Test whether the string contains a percent sign followed by exactly two hexadecimal digits, e.g. the regular expression /%[0-9A-Fa-f]{2}/. If it matches, the value is probably already encoded and you should decode it (or skip encoding) rather than encode it again. This is a heuristic, not a proof — a raw string can legitimately contain "%20" as literal text — so the safest design is to track encoding state explicitly instead of guessing it.

How do I fix a double-encoded URL?

Decode it one extra time. A double-encoded value needs two decode passes to get back to the original: decodeURIComponent('%2520') gives '%20', and decoding that again gives ' '. The real fix, though, is upstream: find the layer that is encoding an already-encoded value and remove the duplicate encode so the string is only encoded once, at the point where the URL is built.

What is the %25 in a URL?

%25 is the percent-encoded form of the percent sign (%) itself, because "%" starts every escape sequence and therefore cannot appear literally. A lone %25 is legitimate (a real percent sign in the data). But %25 sitting in front of two more hex digits — like %2520 or %253A — usually means an already-encoded string was encoded a second time.

Is double encoding a security risk?

It can be. "Double encoding" is a recognised attack technique: an attacker encodes a payload twice so a security filter or WAF, which decodes once, sees a harmless string, while a backend that decodes again reconstructs the dangerous one. Blocking "../" but not "%252e%252e%252f" is the classic path-traversal example. The defence is to decode fully (recursively) until the string stops changing, then validate — never validate before you have finished decoding.

What is the difference between encodeURI and encodeURIComponent?

encodeURI is for encoding a whole URL and leaves structural characters like : / ? # & = intact, so it will not break the URL's shape. encodeURIComponent is for encoding a single piece of data — one query value or path segment — and escapes those structural characters too, so the data cannot break out of its slot. Use encodeURIComponent for individual values; use encodeURI (rarely) only when you already hold a complete URL string.

Does URL encoding prevent XSS or SQL injection?

No. URL encoding is for syntactic correctness — keeping data inside its parameter — not for security. A value can be perfectly URL-encoded and still be an XSS payload once the server decodes it and writes it into HTML without HTML-escaping, or an SQL injection once it reaches a query without parameterization. Encode for the URL, then apply the right defence for the next context: HTML escaping for HTML, parameterized queries for SQL.

url encodingpercent encodingweb developmentUTF-8security