Double encoding occurs when already-encoded characters get encoded again. The % in %20 becomes %25, making %20 into %2520. This breaks URLs and causes confusing bugs.
Key Takeaways
- 1Double encoding turns %20 into %2520
- 2It happens when you encode an already-encoded URL
- 3Common in nested URL parameters (redirect_uri)
- 4Always decode before re-encoding if unsure
- 5Use URL/URLSearchParams classes to avoid manual encoding
"Double encoding attacks can bypass security filters by encoding user input twice. The first decoding process is done by the web server and the second by the backend."
What Double Encoding Looks Like
Double encoding creates a telltale pattern: the sequence %25 followed by two hex digits. When you see %2520 instead of %20, or %253D instead of %3D, you've found double encoding. This pattern makes debugging easier once you know what to look for.
The table below shows how common characters transform through single and double encoding stages. Notice how the encoded percent sign creates the distinctive %25 prefix.
| Original | Single Encoded | Double Encoded |
|---|---|---|
| hello world | hello%20world | hello%2520world |
| a=b | a%3Db | a%253Db |
| Tom & Jerry | Tom%20%26%20Jerry | Tom%2520%2526%2520Jerry |
The most common cause of double encoding is encoding a string that's already been encoded. This often happens when you're not sure whether your input is encoded or not, so you encode it "just to be safe." Unfortunately, this defensive approach backfires badly.
Common Causes
// BAD: Encoding an already-encoded URL
const alreadyEncoded = 'https://example.com/search?q=hello%20world';
const doubleEncoded = encodeURIComponent(alreadyEncoded);
// "https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world"
// The %20 became %2520!
// BAD: Manually encoding then using URL API
const value = encodeURIComponent('hello world');
const url = new URL('https://example.com');
url.searchParams.set('q', value);
// q=hello%2520world (URL API encoded the already-encoded %)
// GOOD: Let the API handle encoding
const url = new URL('https://example.com');
url.searchParams.set('q', 'hello world');
// q=hello+world (correct!)The first example shows what happens when you pass an already-encoded URL to encodeURIComponent(). The second shows a subtler bug: manually encoding a value then passing it to the URL API, which encodes it again. The fix is simple—always pass raw, unencoded values to the URL API and let it handle encoding.
The trickiest double-encoding scenarios involve nested URLs, where a complete URL appears as a parameter value inside another URL. OAuth flows are the classic example.
Nested URLs (redirect_uri)
OAuth and similar authentication flows require passing a complete URL (the redirect_uri) as a query parameter. This legitimate use case requires single encoding of the nested URL. Problems arise when code that builds the outer URL encodes everything again, or when the nested URL arrives already encoded.
// The redirect_uri parameter contains a full URL
const redirectUri = 'https://myapp.com/callback?token=abc123';
// BAD: Manual encoding leads to double encoding
const authUrl = 'https://auth.example.com/authorize' +
'?redirect_uri=' + encodeURIComponent(redirectUri);
// This is actually correct for the first level
// But if you then encode the whole thing again:
const doubleEncoded = encodeURIComponent(authUrl);
// Now the already-encoded redirect_uri is double-encoded!
// GOOD: Use URL API consistently
const auth = new URL('https://auth.example.com/authorize');
auth.searchParams.set('redirect_uri', redirectUri);
console.log(auth.href);
// The API handles encoding correctlyThis example shows the right way to build OAuth URLs. Pass the raw redirect URL (with its original query parameters) to searchParams.set(), and the URL API encodes it exactly once. The nested URL's ? and = characters become %3F and %3D, not %253F and %253D.
When you suspect double encoding but aren't sure, you can detect it programmatically. The key insight is that double-encoded strings change when decoded twice.
Detecting Double Encoding
function isDoubleEncoded(str) {
// If decoding twice gives different results, it was double-encoded
const decoded = decodeURIComponent(str);
const decodedAgain = decodeURIComponent(decoded);
return decoded !== decodedAgain;
}
// Test
isDoubleEncoded('hello%2520world'); // true
isDoubleEncoded('hello%20world'); // false
// Fix double encoding
function fixDoubleEncoding(str) {
let result = str;
while (isDoubleEncoded(result)) {
result = decodeURIComponent(result);
}
return result;
}The isDoubleEncoded() function works by comparing one decode pass against two. If they differ, the string has multiple encoding layers. The fixDoubleEncoding() function keeps decoding until it reaches the original value. Use these cautiously—they can mask the root cause if applied blindly.
Prevention is always better than detection. Following consistent encoding practices eliminates double encoding bugs at their source.
Prevention
The best defense against double encoding is establishing clear boundaries where encoding happens. Ideally, encode once at the point where you build URLs, and never re-encode received values without first decoding them.
| Do | Don't |
|---|---|
| Use URL/URLSearchParams APIs | Manually concatenate with encodeURIComponent |
| Encode raw values only | Encode already-formatted URLs |
| Decode first if source is unknown | Assume input is unencoded |
| Keep encoding at system boundaries | Encode at multiple layers |
The "encode at system boundaries" principle is key: your internal code should work with unencoded values, and encoding should happen only when data leaves your system (in a URL, API request, etc.). This approach makes the encoding responsibility clear and prevents accidental double encoding.