Double URL Encoding — How It Happens and Why It Breaks Your API Requests
Double URL encoding is one of those bugs that looks impossible at first glance.
You see this in your logs:
redirect=https%253A%252F%252Fexample.com
And you think: That does not look right. But I only encoded it once.
The problem is that somewhere between the frontend and the backend, encoding was applied multiple times. The % character itself got encoded into %25, turning %3A into %253A and %2F into %252F.
The result is a URL that looks encoded, decodes to what looks like an encoded URL, and silently fails in redirects, OAuth flows, and API calls.
This guide covers how double encoding happens, how to identify it, and how to prevent it in every layer of your stack.
How Double Encoding Happens
Single encoding transforms characters like this:
space → %20
: → %3A
/ → %2F
Double encoding applies the same transformation again:
%20 → %2520
%3A → %253A
%2F → %252F
The % sign (ASCII 37, hex 25) becomes %25. The rest of the sequence stays. So %20 becomes %2520 — the 25 is the encoded %, and 20 is the original encoded space.
Visually:
Original: : → %3A
Double: %3A → %253A
↑
%25 = encoded %
How Double Encoding Occurs in Practice
Double encoding usually happens when different layers of an application each apply encoding independently, without knowing the data is already encoded.
Scenario 1: Frontend and Middleware Both Encode
// Frontend: encodes a redirect URL
const redirect = encodeURIComponent("https://example.com/callback");
// Result: https%3A%2F%2Fexample.com%2Fcallback
// Middleware: encodes the query string again
const url = `https://auth.com/login?redirect=${encodeURIComponent(redirect)}`;
// Result: https://auth.com/login?redirect=https%253A%252F%252Fexample.com%252Fcallback
Each layer does the right thing in isolation. Together, they produce double encoding.
Scenario 2: Library Auto-Encoding + Manual Encoding
// Some HTTP libraries auto-encode query parameters
const params = {
q: encodeURIComponent("hello world") // Already encoded: hello%20world
};
// The library encodes again
const url = new URL("https://example.com/search");
url.searchParams.set("q", "hello%20world");
// url.toString() → https://example.com/search?q=hello%2520world
Scenario 3: Backend Framework Double-Decoding
// Java Spring: the framework decodes query params automatically
@GetMapping("/search")
public String search(@RequestParam String q) {
// q is already decoded by Spring
// If you decode again:
return URLDecoder.decode(q, "UTF-8"); // DOUBLE DECODE
}
The reverse can also happen — data decoded when it should not be.
Detecting Double Encoding
Visual Signs
Look for %25 in your URLs:
Normal: ?q=hello%20world
Double: ?q=hello%2520world
Normal: ?redirect=https%3A%2F%2Fexample.com
Double: ?redirect=https%253A%252F%252Fexample.com
If you see %25 followed by two hex digits, you have double encoding.
Programmatic Detection
function isDoubleEncoded(value) {
return /%25[0-9a-fA-F]{2}/.test(value);
}
console.log(isDoubleEncoded("hello%20world")); // false
console.log(isDoubleEncoded("hello%2520world")); // true
Counting Decode Layers
def count_encoding_layers(value):
count = 0
from urllib.parse import unquote
current = value
while '%' in current:
try:
decoded = unquote(current)
if decoded == current:
break
current = decoded
count += 1
except:
break
return count
print(count_encoding_layers("hello%20world")) # 1
print(count_encoding_layers("hello%2520world")) # 2
print(count_encoding_layers("hello%252520world")) # 3
Real-World Scenarios
OAuth Redirect Failures
OAuth providers are extremely sensitive to URL encoding mismatches.
Expected: redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback
Received: redirect_uri=https%253A%252F%252Fmyapp.com%252Fcallback
The provider compares the received redirect URI against the one registered in the developer console. They do not match. The OAuth flow fails with:
invalid redirect_uri
No hint about encoding. No clue about double encoding. Just a cryptic failure.
Payment Gateway Callbacks
Payment providers like Stripe and PayPal compute HMAC signatures over redirect URLs. Double encoding changes the URL, which changes the signature, causing verification failures.
// Correct URL used for signature
const signedUrl = "https://myapp.com/callback?session_id=abc123";
const signature = hmac(signedUrl, secret);
// Actual URL after double encoding
const actualUrl = "https://myapp.com/callback?session_id=abc%2523123";
// signature mismatch → payment verification fails
API Query Parameter Corruption
// User searches for: "50% off"
const raw = "50% off";
// Frontend encodes
const encoded = encodeURIComponent(raw);
// "50%25%20off"
// Backend decodes once
const once = decodeURIComponent("50%25%20off");
// "50% off" ← looks right
// Backend decodes again (auto-middleware + manual)
const twice = decodeURIComponent("50% off");
// This may error because % of is not a valid escape
CDN Signed URLs
CloudFront, Cloudflare, and Akamai signed URLs use the exact request path for signature computation:
Signed path: /content/file.pdf?token=abc123%20expires=3600
Actual path: /content/file.pdf?token=abc123%2520expires=3600
The signatures do not match. Access is denied.
Preventing Double Encoding
Principle: Encode Late, Decode Early
Establish clear boundaries in your application:
[User Input] → [Decode if needed] → [Application Logic] → [Encode] → [Network]
- Decode values as soon as they enter your system
- Work with raw values in your application logic
- Encode values only when sending them to external systems
Architecture Rules
// Frontend boundary
function prepareForApi(rawValue) {
// Always encode right before sending
return encodeURIComponent(rawValue);
}
// Backend boundary
function handleFromApi(encodedValue) {
// Always decode right after receiving
return decodeURIComponent(encodedValue);
}
Check Encoding State
function safelyEncode(value) {
// If already encoded, decode first
if (isAlreadyEncoded(value)) {
value = decodeURIComponent(value);
}
return encodeURIComponent(value);
}
Framework Configuration
Some frameworks let you control auto-decoding:
// Spring Boot: disable auto-decoding for specific paths
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUrlDecode(false);
}
}
Fixing Double-Encoded Data
Step 1: Identify the Layer Causing It
Add logging at each boundary:
// Frontend
console.log("Sending to API:", url);
// API Gateway / Middleware
console.log("Received at gateway:", originalUrl);
// Backend
console.log("Received at backend:", req.originalUrl);
Compare the URLs at each layer to find where encoding changes.
Step 2: Decode to the Correct Level
function fixDoubleEncoded(value) {
let current = value;
let prev;
do {
prev = current;
try {
current = decodeURIComponent(current);
} catch {
break;
}
} while (current !== prev && /%[0-9a-fA-F]{2}/.test(current));
return current;
}
This recursively decodes until the value stops changing.
Step 3: Re-encode Once
const fixed = encodeURIComponent(fixDoubleEncoded(brokenValue));
Testing for Double Encoding
describe("double encoding detection", () => {
test("detects normal encoding", () => {
expect(isDoubleEncoded("hello%20world")).toBe(false);
});
test("detects double encoding", () => {
expect(isDoubleEncoded("hello%2520world")).toBe(true);
});
test("fixes double encoded value", () => {
const broken = "hello%2520world";
const fixed = fixDoubleEncoded(broken);
expect(fixed).toBe("hello world");
});
test("single encoding round-trips correctly", () => {
const original = "hello world & special";
const encoded = encodeURIComponent(original);
const decoded = decodeURIComponent(encoded);
expect(decoded).toBe(original);
});
test("no accidental encoding of already-encoded data", () => {
const encoded = encodeURIComponent("hello world");
const reEncoded = encodeURIComponent(encoded);
expect(reEncoded).not.toBe(encoded);
// This is a reality check — encoding encoded data changes it
});
});
Application Layer Checklist
| Layer | Common Cause | Prevention |
|---|---|---|
| Browser | Auto-encoding by URLSearchParams | Use raw values with encodeURIComponent |
| Frontend Framework | Router auto-encoding | Check framework docs for encoding behavior |
| API Gateway | Proxy rewriting URLs | Log raw URLs at gateway |
| Backend Framework | Auto-decoding + manual decode | Know your framework's decode behavior |
| Application Code | Multiple encode calls | Encode once at the boundary |
| Database | Storing encoded values | Store raw values, encode at query time |
Language-Specific Patterns
JavaScript
// Safe pattern for API calls
function callApi(base, params) {
const url = new URL(base);
Object.entries(params).forEach(([key, value]) => {
// URLSearchParams auto-encodes, so pass raw values
url.searchParams.set(key, value);
});
return fetch(url.toString());
}
Python
from urllib.parse import quote, urlencode
def safe_url(base, params):
# urlencode handles encoding, pass raw values
query_string = urlencode(params)
return f"{base}?{query_string}"
Java
public String buildUrl(String base, Map<String, String> params) {
URI uri = new URI(base);
// Let URI handle encoding via multi-arg constructor
return uri.toString();
}
C#
public string BuildUrl(string base, Dictionary<string, string> params)
{
var query = HttpUtility.ParseQueryString("");
foreach (var param in params)
{
query[param.Key] = param.Value; // auto-encodes
}
return $"{base}?{query}";
}
Related Resources
For more on encoding issues and debugging strategies:
-
URL Encoding in OAuth2 — Why Your Redirect URI Keeps Failing OAuth Redirect Encoding
-
URL Encoding the Space Character — + vs %20 Space Encoding Guide
-
Common URL Encoding Mistakes Developers Keep Making Common Mistakes
-
How to Fix Invalid URL Encoding Errors in APIs Fixing Invalid URL Encoding
FAQ
What is double URL encoding?
Double URL encoding happens when percent-encoded data is encoded again, turning % into %25. For example, %20 becomes %2520.
How do I detect double URL encoding?
Look for %25 in your URLs. If you see %25 followed by two hex digits (like %2520 or %253A), the data has been encoded at least twice.
What causes double URL encoding?
It usually happens when different layers of an application (frontend, middleware, backend) each apply encoding independently without knowing the data is already encoded.
How do I fix double-encoded data?
Recursively decode the value until it stops changing, then encode it exactly once before sending.
Does double encoding affect OAuth?
Yes. Double encoding changes the redirect URI, which causes signature validation to fail with cryptic error messages like invalid redirect_uri.
How can I prevent double encoding?
Establish clear encoding boundaries in your architecture: decode data when it enters your system, work with raw values internally, and encode only when sending data to external systems.
Should I store encoded or raw values in my database?
Store raw (decoded) values. Encode them only when constructing URLs or API requests.
Final Thoughts
Double encoding is a silent bug. The URL looks correct at a glance, the error messages are unhelpful, and the root cause is rarely obvious without raw request logging.
The best defense is architectural clarity: define exactly where encoding and decoding happen in your application, enforce boundaries, and never encode data unless you know its current state. When in doubt, decode gradually, log the raw values at each layer, and re-encode only once.
For inspecting suspect URLs during debugging, the URL Encoder/Decoder tool lets you decode layer by layer to see exactly what is happening.