%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:
| Request | Off (default) | On | NoDecode |
|---|---|---|---|
/cgi-bin/env.sh/a%2Fb | 404 | 200, PATH_INFO=/a/b | 200, PATH_INFO=/a%2Fb |
/cgi-bin/env.sh/a%2fb | 404 | 200, PATH_INFO=/a/b | 200, PATH_INFO=/a%2fb |
/cgi-bin/env.sh/a%252Fb | 200, PATH_INFO=/a%2Fb | 200, PATH_INFO=/a%2Fb | 200, 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
%2Fchanges which location fires) but keeps the raw form in$request_uri. Withproxy_passand a URI part it forwards the decoded remainder — the backend in my test received/files/a/bfor a request to/proxy2/a%2Fb. - Apache treats an encoded slash as dangerous by default (
AllowEncodedSlashes Off), offersOn(decode to separator) andNoDecode(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 encoders | Emit uppercase hex — every standard library I tested (JS, Python) already does. Don't hand-lowercase: decode is unaffected, but string-level canonicalization breaks |
| configure servers | Know where your stack decodes: nginx routes on the decoded $uri, Apache on AllowEncodedSlashes, Express on decoded params |
| debug routing | Keep 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