iKit
Technical · 9 min read ·

Double-Encoded URLs: How to Spot and Fix Them (2026)

A double-encoded URL is when a value runs through encodeURIComponent twice. Here's how to spot %2520 in seconds and fix the API bug it caused.

Double-Encoded URLs: How to Spot and Fix Them (2026)

Double-Encoded URLs: How to Spot and Fix Them

A request hits your API, the body looks right, and the response is a 404 — or worse, a silent rewrite to the wrong record. The smoking gun is usually a %25 where you didn't expect one. Double encoding happens when a value passes through encodeURIComponent twice, and once you know the signature you can spot it in a few seconds. This guide shows the trace, the fix, and the layers where the bug usually hides.

TL;DR

  • Double encoding means a value was percent-encoded twice — %20 becomes %2520.
  • Decode the URL once; if the result still contains %XX, you're double-encoded.
  • Most cases come from a client that encodes plus an SDK or proxy that encodes again.
  • The fix is removing one encode call — almost never adding logic, almost always deleting it.
  • Test with a known reserved character like / (%2F vs %252F) to find the layer at fault.

What a Double Encoded URL Actually Is

The percent-encoding rules in RFC 3986 say every non-unreserved character gets replaced by % followed by two hex digits. The catch: % is itself a reserved character, so encoding the encoded form produces a longer string. A space becomes %20. Encoding that %20 again turns the % into %25, leaving you with %2520. The literal characters 2 and 0 ride along unchanged.

The Two-Pass Trace

The clearest way to internalise the pattern is to run it in a console.

const value = "hello world";
const once  = encodeURIComponent(value);
// "hello%20world"
const twice = encodeURIComponent(once);
// "hello%2520world"

That's the entire mechanism. Wherever you see %25XX in a URL, something encoded a value that was already percent-encoded. The downstream decoder produces %XX on its first pass and your application code sees a literal escape sequence instead of the character you actually wanted.

The Telltale %2520

The fastest sniff test is grepping for %2520 in your access logs:

grep '%2520' /var/log/nginx/access.log | head

If you find rows, the URL parser saw %2520 as a literal %25 followed by 20. After one decode pass the resulting string is %20, which the next layer doesn't recognise as a space because at that layer the URL has already been decoded once. The route table looks up a key with a literal %20 in it, misses, and returns 404.

Why It Still Looks Like a URL

Browsers don't visibly complain about double-encoded URLs because the resulting string is still syntactically valid percent-encoded text. https://api.example.com/users/alice%2520smith parses fine — the address bar even shows it correctly. The bug only surfaces when a downstream layer decodes once and compares the result to a string with a single literal space, which never matches.

How a URL Becomes Double-Encoded

Bugs of this shape almost never come from a developer encoding twice on purpose. They come from two parts of the stack each thinking they're the one in charge.

Manual Encode Plus Library Encode

The most common pattern: a request builder encodes the path or query string by hand, and the HTTP client encodes it again on send. Axios, Fetch with URLSearchParams, and Python's requests all handle encoding for you when you pass parameters as an object. Passing a pre-encoded string into them produces the double.

// Bad — fetch will encode again
const q = encodeURIComponent("name=Alice & Bob");
await fetch(`/search?${q}`);

// Good — let URLSearchParams encode
const params = new URLSearchParams({
  name: "Alice & Bob",
});
await fetch(`/search?${params}`);

The rule: pass raw values into helpers that promise to encode for you. Don't help the helper.

Redirect Chains and Edge Caches

Reverse proxies, CDNs, and email click-trackers occasionally normalise the path on the way through. If their normalisation pass re-encodes characters that were already percent-encoded, every link in the chain emits a double-encoded URL. The link in your email is one encoding deep; the CDN's redirect target is two; the application sees two. Worse, you may not see the bug locally because your dev environment skips the proxy.

Logs, Email Trackers, and Click Wrappers

Marketing tools like Mailchimp, Customer.io, and corporate "safe links" wrap outbound URLs in a redirect. Most of them URL-encode the destination as a query parameter. If your tracking ID was already encoded when it went into the email, your final user lands on a URL where the embedded token is mangled. The auth handler decodes once, gets %XX literals where it expected real bytes, and returns 401. If the embedded value happens to be a JWT, this is where a dedicated JWT Decoder earns its keep — paste the suspect token and confirm whether the signature even tokenises.

How to Spot Double Encoding in 30 Seconds

You don't need a debugger. Three quick checks tell you which side of the boundary is at fault.

The Single Decode Test

Paste the URL into a client-side decoder — for example, the iKit URL Encoder / Decoder — and run a single decode. If the output still contains %XX escapes, the value is double-encoded. If it's clean, single encoding is correct. The tool runs locally in your browser, so sensitive query strings with session tokens never leave your machine.

Common signatures to look for after one decode pass:

  • %2520 in a path where you expected a space
  • %252F in a slug or identifier
  • %253D or %2526 in a query parameter
  • %2525 anywhere — that's actually triple-encoded
  • A value that decodes to another %XX escape

Reading Tcpdump Output

Capture the wire bytes between the client and the proxy:

sudo tcpdump -A -s 0 -i any \
  'tcp port 443' | grep 'GET '

You'll see the exact path the client sent. Compare it to the path that arrives at the application. A mismatch — the proxy decoded once and re-encoded — means the proxy is the source. If the path at the client already has %25 in it, the source is upstream of the network capture and you need to look at the request builder.

Regex Sniff for %25XX

A regex catches the pattern in bulk, which is useful in middleware:

const doubleEncoded = /%25[0-9A-Fa-f]{2}/;
if (doubleEncoded.test(req.url)) {
  console.warn("Looks double-encoded:", req.url);
}

Run this against incoming query strings and emit a structured log line. Within a day or two of production traffic you'll have a map of which routes are affected and which clients are sending the bad form. If the body contains JSON with embedded URLs, pipe it through a JSON Decoder first so you can read the structure before chasing the regex.

How to Fix It in Each Layer

The fix is always "encode in one place." Decide which layer owns it and stop encoding everywhere else.

Client and Frontend

If you're hand-building a URL, encode each parameter value once and only once. Best practice is to never concatenate query strings — use URLSearchParams, URL, or the equivalent in your framework. If you're using Next.js, useSearchParams and the Link component handle this for you. If you're using React Router, the to prop of <Link> accepts an object form that encodes correctly.

Backend and API Handler

Most server frameworks — Express, Laravel, Rails, FastAPI — decode the URL once before handing parameters to your route. If you call decodeURIComponent on top of that, you're undoing the implicit decode, which silently hides upstream double encoding instead of fixing it. Trust the framework; if you need to log the raw URI, log it before the route handler sees it. The variable req.originalUrl in Express and request.fullUrl() in Laravel give you the pre-decode form.

Reverse Proxy and Load Balancer

Nginx, Caddy, HAProxy, and most cloud load balancers expose a setting for whether to decode percent-escapes during rewriting. Turn it off for paths that contain user-generated content. The default behaviour normalises %2F (encoded slash) to /, which is sometimes desired and sometimes a security issue — AllowEncodedSlashes On is required on Apache for some routing patterns. Cloudflare's "URL normalisation" toggle and AWS ALB's path matching both have similar knobs; check them when an issue appears only in production.

Reference Table: Common Characters and Double-Encoded Forms

Keep this next to your monitor when debugging — recognising %252F faster than reading the docs is worth a few minutes of training.

Character Single encoded Double encoded
(space) %20 %2520
/ %2F %252F
? %3F %253F
= %3D %253D
& %26 %2526
% %25 %2525
@ %40 %2540
+ %2B %252B
: %3A %253A

Quick Rule of Thumb

Every %25 you see in a URL that wasn't put there as a literal percent sign is one encoding pass too many. Single-decode, then read.

When Double Encoding Is Actually Required

There's one legitimate case: passing a URL as a query parameter to another URL. If the inner URL contains &, =, or #, you must encode it once so the outer URL parses correctly. If the inner URL is itself percent-encoded — a pre-signed S3 link, for example — you're encoding an already-encoded value, and that's double encoding by design rather than by accident. The fix on the receiving end is to decode once to recover the inner URL, then use it directly. This is the rare case where the spec wants you to encode twice.

A Pre-Flight Checklist

Before opening a ticket on a service that "doesn't accept my URL," run this checklist:

  • Does the failing URL contain %25 anywhere? If yes, suspect double encoding first.
  • Does single-decoding the URL give a clean string? If no, you're double-encoded.
  • Does a known reserved character (/, +, =) show up as %252F, %252B, %253D? Same answer.
  • Is the failure shape "works in dev, fails in prod"? Suspect the proxy layer.

If three of these answer yes, the fix is in your encoding pipeline, not the receiving service.

References

Related on iKit

Related posts