Article · Mar 27, 2026 · 6 min read

Double URL encoding, explained

Double-encoding is the most common URL encoding bug in production. The symptom: a URL parameter contains literal %20 instead of a space, or %2520 instead of %20. The cause: something in your data pipeline encoded a value that was already encoded.

This article covers why it happens, how to detect it, and how to fix it permanently.

The mechanic in one paragraph

URL encoding takes the percent character (%) and turns it into %25 (because % has byte value 0x25). So if you have a string that already contains %20 (an encoded space), and you encode it again, the % becomes %25, and you end up with %2520. The 20 at the end is no longer the hex value of a byte — it’s just two literal characters in the data.

What the data looks like at each stage

Stage Value
OriginalHello World
After 1× encodingHello%20World
After 2× encodingHello%2520World
After 3× encodingHello%252520World

Each round doubles the percent signs: %20%2520%252520. You can count the number of encoding passes by counting how many 25s appear in front of a hex code.

How double-encoding happens

1. Framework auto-encoding what was already encoded

The most common cause. Your code does this:

// Suppose query has already been URL-encoded by some upstream step
const query = "Hello%2C%20World%21";
const url = `${base}?q=${encodeURIComponent(query)}`;
// URL now has: ?q=Hello%252C%2520World%2521

The framework or library expects to receive raw text and encode it. You handed it already-encoded text. It encoded again.

2. Storing URL-encoded values in a database, then encoding again on output

An anti-pattern that’s surprisingly common:

// Bad: store URL-encoded values
db.save({ slug: encodeURIComponent("My Article") });
// Stores: { slug: "My%20Article" }

// Later, building a URL from the stored value
const url = `/articles/${encodeURIComponent(article.slug)}`;
// Becomes: /articles/My%2520Article

Best practice: store raw values; encode only at the moment you put them into a URL.

3. Multiple layers of redirect or proxy

Each proxy or redirect that re-emits the URL might re-encode it if its configuration is wrong. A request that bounces through three improperly-configured systems can come out triple-encoded.

4. Copy-paste from one already-encoded place to another

Developer copies an encoded URL from a browser address bar (where it’s displayed encoded) and pastes it into code that re-encodes when building the request.

How to detect double-encoding

Three reliable signals:

1. The visible URL has %25 in it. A bare %25 is suspicious — it’s the encoded form of %, which means the original value contained a literal percent sign. Sometimes that’s legitimate, but usually it indicates the source was already encoded.

2. The decoded output still has %XX sequences in it. Decode the URL once and check the result. If the decoded output has more %XX patterns, it’s double-encoded.

3. The server logs a literal %20 as a value. Your endpoint receives ?q=Hello%2520World, decodes it once to ?q=Hello%20World, and reads the value as the literal string Hello%20World — not a space.

How to fix it once

Decode the value twice (or as many times as it was encoded). For unknown depth, decode in a loop until no more %XX sequences remain:

function decodeAll(value, maxRounds = 16) {
  let current = value;
  let rounds = 0;
  while (rounds < maxRounds && /%[0-9A-Fa-f]{2}/.test(current)) {
    const next = decodeURIComponent(current);
    if (next === current) break;  // no change, bail out
    current = next;
    rounds++;
  }
  return current;
}

decodeAll('Hello%252520World');  // "Hello World"

Our URL decoder has a "Decode recursively" checkbox that does this — useful when you don’t know how many times something was encoded.

How to fix it permanently

Find the layer that’s adding the extra encoding pass. Common culprits:

Frameworks that auto-encode in the wrong direction. Some frameworks encode query parameters automatically when you set them via a method call. If you also encode manually, you double up.

// Wrong — manual encode + framework encode
const params = { q: encodeURIComponent(query) };
const url = new URL("https://example.com/search");
url.search = new URLSearchParams(params).toString();
// q value is now double-encoded

// Right — let the framework handle it
const params = { q: query };  // raw value
const url = new URL("https://example.com/search");
url.search = new URLSearchParams(params).toString();

Storing already-encoded data. Audit your database. If columns contain % characters, check whether you’re storing percent-encoded data. Switch to storing raw data and encoding only at output time.

Templating engines that pre-encode. Some templating engines encode URL-attribute values automatically. If you encode in code before passing to the template, you double up. Check the template engine’s docs.

The defensive coding approach

If you receive a value and aren’t sure whether it’s already encoded, you have two choices:

Decode first, then re-encode. Safe but lossy if the value contained legitimate %XX sequences that weren’t encoding.

function normalizeAndEncode(value) {
  const decoded = decodeURIComponent(value);  // peel off any existing encoding
  return encodeURIComponent(decoded);
}

Detect and skip if already encoded. Inspect the value and only encode if it doesn’t look encoded already.

function smartEncode(value) {
  const looksEncoded = /%[0-9A-Fa-f]{2}/.test(value);
  return looksEncoded ? value : encodeURIComponent(value);
}

Both work. The second is more efficient but more fragile (a value containing a literal %XX pattern that wasn’t encoding gets misidentified).

Bottom line

Double-encoding is preventable. Two rules eliminate 95% of cases:

  1. Encode exactly once — at the moment of building the URL, never before.
  2. Store raw values, not encoded ones.

For the remaining cases (working with legacy code, third-party APIs that return encoded data), use the recursive decode option on our URL decoder or implement a decode-loop in your code.


Found this useful? Try the URL decoder, the URL encoder, or browse all tools.

More reading

From the blog.