%2F or %2f? Case Sensitivity in Percent-Encoding and Why Servers Disagree

A staging bug report landed on my desk: "Links with a lowercase %2f return 404, but the uppercase %2F version works." That smells like a case-sensitivity bug, so I stood up three real servers — nginx 1.31.3, Apache 2.4.62, and Express on Node 24 — and threw the same requests at all of them. Spoiler: in every test, the case never mattered. What mattered was something else that looks like a case problem, and understanding why requires reading the spec more carefully than most tutorials do.

What the Spec Actually Says

RFC 3986 section 2.1 defines the escape as % followed by two hex digits, and states that A–F and a–f are equivalent when decoding. %2F and %2f are the same byte, 0x2F. Every decoder I tested agrees:

decodeURIComponent("%2f"); // "/"
decodeURIComponent("%2F"); // "/"
from urllib.parse import unquote
unquote("%2f")  # '/'
unquote("%2F")  # '/'

I ran both and got a slash in all four cases. Case only matters when encoded strings are compared as strings — cache keys, deduplication, canonical forms for signatures. For decoding, case is noise.

The Test Bench

Express: Route on the Raw Path, Decode the Params

I started with a minimal Express echo app so I could see exactly what the framework receives:

const express = require("express");
const app = express();

app.get("/files/:name", (req, res) => {
  res.type("text/plain").send(
    `url=${req.url}\npath=${req.path}\nparam.name=${JSON.stringify(req.params.name)}\n`
  );
});

app.get("/files/a/b", (req, res) => {
  res.type("text/plain").send(`url=${req.url}\nMATCHED literal /files/a/b\n`);
});

app.listen(17999);

The Request Matrix

Then I sent requests with curl --path-as-is (without it, curl rewrites dot segments and I'd be testing curl, not the server). Here is what Express reported, verbatim from my terminal:

GET /files/a%2Fb   →  url=/files/a%2Fb, param.name="a/b"   (matched /files/:name)
GET /files/a%2fb   →  url=/files/a%2fb, param.name="a/b"   (matched /files/:name)
GET /files/a/b     →  url=/files/a/b                      (matched literal route)
GET /files/a%252Fb →  url=/files/a%252Fb, param.name="a%2Fb"

Three findings. First, %2F and %2fb behave identically — case truly doesn't matter. Second, /files/a%2Fb and /files/a/b hit different routes on the same server, even though the route parameter ends up containing the same decoded value "a/b". Third, the parameter is decoded exactly once: %252F becomes "a%2Fb".

nginx: Routing Happens on the Decoded URI

My nginx config had an exact location and a prefix location:

location = /static/a/b { return 200 "matched exact /static/a/b"; }
location /static       { return 200 "matched prefix /static"; }

The requests, with nginx's own $uri and $request_uri echoed back:

GET /static/a%2Fb   →  matched exact /static/a/b, uri=/static/a/b,   request_uri=/static/a%2Fb
GET /static/a%2fb   →  matched exact /static/a/b, uri=/static/a/b,   request_uri=/static/a%2fb
GET /static/a%252Fb →  matched prefix /static,     uri=/static/a%2Fb, request_uri=/static/a%252Fb

nginx matched locations against the decoded URI: %2F became a real / before routing, so the exact location /static/a/b fired. Case made no difference. Double encoding survived as one layer. This is the point almost no tutorial mentions: your router and your access log can disagree about the same request — the log shows %2F, the router saw /.

Apache: An Encoded Slash Is Rejected by Default

macOS ships Apache 2.4.62, so I tested it with a CGI script that echoes REQUEST_URI and PATH_INFO, across all three AllowEncodedSlashes settings:

RequestOff (default)OnNoDecode
/cgi-bin/env.sh/a%2Fb404200, PATH_INFO=/a/b200, PATH_INFO=/a%2Fb
/cgi-bin/env.sh/a%2fb404200, PATH_INFO=/a/b200, PATH_INFO=/a%2fb
/cgi-bin/env.sh/a%252Fb200, PATH_INFO=/a%2Fb200, PATH_INFO=/a%2Fb200, PATH_INFO=/a%2Fb

The default rejects any encoded slash — upper or lower case — with a 404. On decodes it into a real separator, making /a%2Fb indistinguishable from /a/b. NoDecode keeps it as data, and preserves the original case (%2fb stays %2fb). In every mode, REQUEST_URI kept the raw, original string — which is why it's the field I always check first when debugging routing.

Why Servers Disagree

RFC 3986 defines the encoding. It says nothing about when a server must decode, or whether a decoded slash becomes a path separator. Each server fills that gap differently, and that's the entire story:

  • nginx decodes before location matching (a %2F changes which location fires) but keeps the raw form in $request_uri. With proxy_pass and a URI part it forwards the decoded remainder — the backend in my test received /files/a/b for a request to /proxy2/a%2Fb.
  • Apache treats an encoded slash as dangerous by default (AllowEncodedSlashes Off), offers On (decode to separator) and NoDecode (keep as data).
  • Express routes on the raw path but decodes route parameters — so a parameter can contain a / that the router never saw as a separator.

That last one bit me. A files endpoint that did path.join(ROOT, req.params.name) — the router guarantees nothing about / inside a parameter, because %2F in the request maps to a plain / in req.params.name while never registering as a path segment during matching. The fix was validating the decoded parameter, not the raw path.

The Staging Bug, Explained

Back to the bug report: lowercase %2f 404s, uppercase %2F works. Given the test data, I don't believe the case mattered. A server that rejects an encoded slash — like Apache with the default AllowEncodedSlashes Off, or a WAF rule that pattern-matches one case — rejects both forms equally. What I suspect actually happened: the failing links always happened to be generated with a lowercase encoder (a hand-rolled .toLowerCase() in one code path, which I've seen plenty of), and the 404 was the slash rejection, not the case. The case was a coincidence; the byte was the cause. When you see "case-dependent" URL behavior in production, chase the layer that rejects the byte, not the hex case.

The Encoder That Causes the "Case Bug"

Where does a lowercase %2f even come from? Standard encoders emit uppercase — I verified encodeURIComponent("/") gives %2F and Python's quote("/", safe="") gives %2F (note the empty safe: Python's default keeps / raw). Lowercase hex is the fingerprint of a hand-rolled encoder. The one I see most often is a single-line regex:

function badEncode(s) {
  return s.replace(/[^\w.\-~]/g, (c) => "%" + c.charCodeAt(0).toString(16).toLowerCase());
}
console.log(badEncode("a/b")); // "a%2fb"  — lowercase, and it "works" for ASCII
console.log(badEncode("中"));  // "%4e2d"  — completely wrong
console.log(decodeURIComponent("%4e2d")); // "N-"  — not 中

I ran this, and the outputs are a%2fb, %4e2d, and N-. Two failures in three lines of code. First, toLowerCase() produces the lowercase escapes that started this investigation — harmless on decode, but it breaks any string-level canonicalization (cache keys, signature inputs, dedupe tables), and it's a tell that this code path was never tested against a real URL parser. Second, and much worse, charCodeAt returns UTF-16 code units, not UTF-8 bytes. becomes %4e%2d — the code units 4E and 2D — which decodes to the ASCII characters N-. The encoder silently turns non-ASCII input into different characters. That's the real reason I distrust hand-rolled encoders on sight: they fail on exactly the inputs a reviewer can't eyeball. (The correct chain — code point → UTF-8 bytes → uppercase %XX — is what the emoji post walks through.)

Practical Rules

If you…Then…
write encodersEmit uppercase hex — every standard library I tested (JS, Python) already does. Don't hand-lowercase: decode is unaffected, but string-level canonicalization breaks
configure serversKnow where your stack decodes: nginx routes on the decoded $uri, Apache on AllowEncodedSlashes, Express on decoded params
debug routingKeep the raw URI. $request_uri (nginx), REQUEST_URI (Apache), and req.url (Express) all preserved the original form in my tests

One more from the bench, because it changed how I read logs: all three servers kept the raw URI available somewhere even when they decoded elsewhere. If you only have access to the decoded view, you cannot tell whether a %2F ever existed — which is exactly how "case-sensitive routing" ghost stories start. When a bug report says a URL behaves differently based on hex case, my first move is now to capture the raw request line. The case never survives that check as the culprit.

Try Our Tools

  • URL Parser — see the exact components of a URL, including what percent-encoding looks like in each part

Related reading:

— The URL Decode Online Team