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

LayerCommon CausePrevention
BrowserAuto-encoding by URLSearchParamsUse raw values with encodeURIComponent
Frontend FrameworkRouter auto-encodingCheck framework docs for encoding behavior
API GatewayProxy rewriting URLsLog raw URLs at gateway
Backend FrameworkAuto-decoding + manual decodeKnow your framework's decode behavior
Application CodeMultiple encode callsEncode once at the boundary
DatabaseStoring encoded valuesStore 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:


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.