# When does URL normalization change the resource identity? **Date of Record:** 2026 November 2 **Scope:** Applies to systems that compare, deduplicate, or cache by URL, where URLs may be normalized (case, dot-segments, default ports, encoding) before comparison. Excludes systems that treat URLs as opaque bytes end-to-end. ## Core Distinction **Predicate.** Two URLs identify the same resource if and only if they are identical after *permitted* normalization (RFC 3986 section 6) and differ only in *forbidden* variations the server treats as distinct. Case of scheme and host is permitted; case of path is not. Dot segments are permitted. Percent-encoding of unreserved characters is permitted. Whether trailing slash and default port are permitted is server-defined and must be explicit. **Failure.** A cache normalizes URLs by lowercasing the whole string. `/Users/Alice` and `/users/alice` become the same key. The first response is served for the second request, which is a different resource. Alternatively, `/a/./b` is not normalized, so two cache entries hold the same content. **Asymmetry.** Each normalization step is individually defensible. The composition produces collisions (under-normalization in the path) or false hits (over-normalization in the path). Only a comparison against the resource server's own equality check reveals which. ## What Was Observed **Signature.** Cache serving response A for request B. Deduplication merging records that the server treats as distinct. Authorization checks bypassed because two URLs that the resource server treats as different are compared as equal. **Trace.** A shortener caches by lowercased URL. `/Case/Sensitive` and `/case/sensitive` share a cache slot. If one is public and the other is private, the public request can be served private content, and vice versa. The cache is functioning as designed; the normalization was wrong for the resource. **Why monitoring stays green.** The cache has a high hit rate. Latency is low. Errors do not appear until two paths that collide have meaningfully different content, which is a content state, not a code state. ## Condition of Validity URL normalization is safe for a comparison only while at least one holds: 1. **Server equality.** The resource server's own equality is known and the normalization matches it exactly. 2. **Minimal normalization.** Only scheme and host case are lowered; path, query, and fragment are preserved. 3. **Signed URLs.** URL identity is established by a signature or content hash, not by a normalization of the string. Condition 1 is the only one that is exactly correct for arbitrary servers. ## Decision Boundary - Lowercase scheme+host only: safe for most servers, matches RFC 3986. - Lowercase whole URL: collisions on case-sensitive paths. - Strip dot-segments: safe per RFC 3986, but the server may not implement the same. - Strip default port, trailing slash, or fragment: server-defined; must be verified per resource class. ## Operational Effect The defect concentrates in the difference between what RFC 3986 permits and what the specific resource server implements, which is almost never documented. Remediation is minimal normalization (scheme+host only) plus signature or content-hash comparison for identity. The trap is to broaden normalization until cache hit rate improves. That improves the metric and silently worsens correctness. ```python from urllib.parse import urlparse from dataclasses import dataclass class UrlIdentityError(ValueError): """Raised when URL normalization merges resources the server treats as distinct.""" @dataclass(frozen=True) class UrlGate: url: str normalize_scheme_and_host: bool = True normalize_path: bool = False def verify(self) -> bool: parsed = urlparse(self.url) if not parsed.scheme: raise UrlIdentityError("URL has no scheme: identity is ambiguous") if self.normalize_path: raise UrlIdentityError( "path normalization: /Users/Alice and /users/alice may be distinct resources" ) if not self.normalize_scheme_and_host: raise UrlIdentityError( "scheme and host not normalized: RFC 3986 permits case folding here" ) return True def dispatch_url_compare(gate: UrlGate) -> str: try: gate.verify() except UrlIdentityError: return "signature-or-content-hash" return "normalized-compare" ```