X Xerobit

Double URL Encoding — What It Is, Why It Happens, and How to Prevent It

Double URL encoding happens when an already-encoded URL is encoded again, turning %20 into %2520. Learn how to detect it, prevent it in APIs and proxies, and why attackers...

Mian Ali Khalid · · 4 min read
Use the tool
URL Encoder / Decoder
Percent-encode and decode URLs per RFC 3986.
Open URL Encoder / Decoder →

Double encoding happens when %20 (encoded space) gets encoded again to %2520 (%%25, so %25 + 20 = %2520). It’s a common bug in proxy chains and a known security bypass technique.

Encode and decode URL components with the URL Encoder.

How double encoding happens

Step 1: User input "hello world"
Step 2: First encoding: "hello%20world"     (%20 = space)
Step 3: Second encoding: "hello%2520world"  (%25 = %, encoded again)

The server decodes once: "hello%20world"    (still encoded!)
The application sees: "%20" as literal text, not a space

Or from the attacker’s perspective:

URL path: /admin/../../etc/passwd
Step 1: Encoded:        /admin/%2E%2E/%2E%2E/etc/passwd
Step 2: WAF blocks %2E%2E (encoded dots in traversal)

Double-encode step:     /admin/%252E%252E/%252E%252E/etc/passwd
WAF sees %252E%252E → allows through (doesn't recognize as traversal)
Server decodes once:    /admin/%2E%2E/%2E%2E/etc/passwd
Second decode:          /admin/../../etc/passwd
Path traversal succeeds!

Detect double encoding

// Check if a string contains percent-encoded percent signs:
function isDoubleEncoded(str) {
  return /%25[0-9a-fA-F]{2}/.test(str);
  // Matches: %25XX where XX is a hex pair
  // %2520 → % = %25, 20 = space (was already encoded)
}

isDoubleEncoded('hello%2520world');  // true (double-encoded space)
isDoubleEncoded('hello%20world');    // false (single-encoded space)

// Decode double-encoded URL:
function safeDecodeURL(url) {
  let decoded = url;
  try {
    // Keep decoding until stable:
    while (decoded !== decodeURIComponent(decoded)) {
      decoded = decodeURIComponent(decoded);
    }
  } catch {
    return url; // Return original if decoding fails
  }
  return decoded;
}

Prevent double encoding in JavaScript

// ❌ Don't encode something that's already encoded:
function badEncode(value) {
  const encoded = encodeURIComponent(value);  // "hello%20world"
  return encodeURIComponent(encoded);          // "hello%2520world" — bug!
}

// ✅ Encode raw values only:
function buildURL(base, params) {
  const url = new URL(base);
  for (const [key, value] of Object.entries(params)) {
    // URLSearchParams handles encoding correctly — never double-encodes:
    url.searchParams.set(key, value);  // Pass raw string
  }
  return url.toString();
}

buildURL('https://example.com/search', { q: 'hello world', tag: 'a&b' });
// "https://example.com/search?q=hello+world&tag=a%26b"
// ❌ Common bug in Express path parameters:
app.get('/files/:filename', (req, res) => {
  const filename = req.params.filename; // Already decoded by Express
  const filepath = path.join(__dirname, 'uploads', encodeURIComponent(filename));
  // ❌ Double-encoding! Express decoded it, you re-encoded it.
  // req.params.filename is already decoded
});

// ✅ Use the decoded value directly:
app.get('/files/:filename', (req, res) => {
  const filename = req.params.filename; // "hello world.pdf" (already decoded)
  const filepath = path.join(__dirname, 'uploads', filename);
  // ✅ path.join handles the raw filename correctly
});

Double encoding as a security bypass

Double encoding is used in path traversal, XSS, and SQL injection bypass attacks:

Attack: GET /files/%252e%252e%252f%252e%252e%252fetc/passwd
                  ↑ %25 = %, %2e = .
                  After first decode: %2e%2e%2f%2e%2e%2f
                  After second decode: ../../

WAF pattern matching for "../": didn't match %252e%252e%252f
Application decoded twice: ../../../etc/passwd

Defense: Always normalize and decode URLs fully before applying security checks. Use well-tested security middleware rather than manual pattern matching.

// Security: normalize before checking:
function normalizeAndValidatePath(urlPath) {
  // Decode until stable:
  let decoded = urlPath;
  for (let i = 0; i < 10; i++) {
    const next = decodeURIComponent(decoded);
    if (next === decoded) break;
    decoded = next;
  }
  
  // Now resolve path traversal:
  const resolved = path.posix.normalize(decoded);
  
  // Ensure path stays within allowed directory:
  const ALLOWED = '/uploads/';
  if (!resolved.startsWith(ALLOWED)) {
    throw new Error('Path traversal detected');
  }
  
  return resolved;
}

Related posts

Related tool

URL Encoder / Decoder

Percent-encode and decode URLs per RFC 3986.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.