Normalizing Query Strings in the Cache Key
After working through this guide you will be able to take a route whose query string is fragmenting the cache into thousands of near-identical entries and reduce it to a small, bounded, reviewed set of keyed parameters — without ever serving the wrong body. The procedure is six steps: inventory what the application reads, drop the tracking noise, choose an allow-list, sort the survivors, decide where case folding is safe, and prove the result with a curl matrix.
Query strings are the noisiest input to a cache key because they are the only part of the URL that anyone — your marketing team, an ad network, a third-party SDK, a bored attacker — can append to at will. The path is yours; the query string is public writing space. Left unnormalized it is the reason a page with a 99% cacheable body reports a 30% hit ratio. The wider theory of key composition lives in Cache Key & Vary Configuration; this guide is the hands-on procedure for the query component specifically.
Key implementation objectives:
- Produce a written inventory of every query parameter each route genuinely varies on.
- Remove tracking parameters from the key while leaving them in the URL the origin and the browser see.
- Deploy an allow-list on Cloudflare, CloudFront and a Worker, and know why an allow-list beats a deny-list.
- Prove with a reproducible curl matrix that two differently-spelled URLs now share one cache entry.
Prerequisites and environment
You need edit rights on the CDN configuration for one route, a way to read origin access logs or an analytics export for the last 30 days, and a shell with curl. The Terraform example assumes 1.5 or newer with the AWS provider; the Worker example assumes Wrangler 3.
curl --version | head -1 # curl 8.5.0
terraform version | head -1 # Terraform v1.9.x
npx wrangler --version # 3.x
Pick one route to start with — ideally a high-traffic, fully public page such as a product listing or a marketing landing page. Do not begin with an authenticated route; the cost of a mistake there is a data leak rather than a missed cache hit. Confirm the route is currently cacheable at all before you tune anything, because a Set-Cookie or Cache-Control: private on the response makes key work irrelevant:
curl -sSD - -o /dev/null 'https://shop.example.com/search?q=boots' \
| grep -iE 'cache-control|set-cookie|cf-cache-status|^x-cache'
Step 1 — Inventory the parameters the application actually reads
Normalization decisions must come from the code, not from intuition. There are two sources of truth and you need both.
The first is the application itself. Grep the route handler for every read of the query string and write down the names. In a typical framework that is one or two call shapes:
# Node / Express style
grep -rnE "req\.query\.[A-Za-z_]+|searchParams\.get\(" src/routes/search/ | sort -u
# Python / Django style
grep -rnE "request\.GET\.get\(|request\.query_params\.get\(" app/search/ | sort -u
The second is production traffic, which tells you what the world actually sends — including parameters no one on the team has heard of. Pull the distinct query keys from a day of access logs:
awk '{print $7}' /var/log/nginx/access.log \
| grep -o '?.*' | tr '?&' '\n\n' | cut -d= -f1 \
| sort | uniq -c | sort -rn | head -40
Expected output is a lopsided distribution dominated by tracking tokens:
481203 utm_source
480944 utm_medium
402118 q
311876 fbclid
208554 page
99412 gclid
87310 sort
4402 __cf_chl_tk
Now build the inventory table. Every parameter goes in exactly one row, and every row gets a decision that you can defend in review.
| Parameter | Read by the app? | Changes the body? | Decision |
|---|---|---|---|
q |
Yes | Yes | Key on it |
page |
Yes | Yes | Key on it |
sort |
Yes | Yes | Key on it, normalize case |
lang |
Yes | Yes | Key on it, fold to a fixed locale set |
utm_source, utm_medium, utm_campaign |
No | No | Drop from key |
fbclid, gclid, mc_eid, msclkid |
No | No | Drop from key |
sig, expires |
Yes, by the edge | Yes, controls access | Key on it, never drop |
__cf_chl_tk |
No, platform-internal | No | Drop from key |
The middle column is the one people get wrong. A parameter can be read by the application without changing the body — a ?ref= value written straight into a server-side analytics event is read but never rendered. Those belong out of the key. The reverse mistake is far more expensive: a parameter that changes the body but is not in the key means one visitor’s result set gets served to everyone.
Step 2 — Separate tracking parameters from meaningful ones
Tracking parameters share three properties: they are appended by systems you do not control, they are consumed by client-side JavaScript rather than by your server renderer, and their value space is effectively infinite. Any one of those properties is enough to disqualify them from the key; all three together make them the single largest source of cache fragmentation on the public web.
The practical list to strip is short and stable:
utm_source utm_medium utm_campaign utm_term utm_content utm_id
fbclid gclid gbraid wbraid msclkid dclid ttclid twclid
mc_cid mc_eid _ga _gl igshid ref ref_src yclid
Removing them from the key does not remove them from the request. This distinction matters enough to state plainly: the cache key is a lookup string the CDN computes locally, while the request forwarded to the origin still carries the full original URL. Analytics, attribution and server-side logging are untouched. The only thing that changes is that ?q=boots&utm_source=email and ?q=boots&utm_source=push stop being two objects.
Step 3 — Choose an allow-list, and understand why
You have two ways to express the rule. A deny-list says “key on everything except these names”. An allow-list says “key on these names and nothing else”. They look symmetric and they are not.
The allow-list wins for one structural reason: it makes the keyspace a closed set that you own, and a deny-list cannot, because you cannot enumerate the names you have never seen. Under a deny-list, a script appending ?_=1, ?_=2, ?_=3 walks straight past the cache and lands on your origin at full request rate — the cheapest denial-of-service on the internet, and one that shows up in your dashboards as a mysterious hit-ratio collapse rather than an attack. Under an allow-list the same traffic collapses onto one cached object and never reaches you.
The allow-list’s cost is real and worth naming: when the application starts varying on a new parameter, someone must add it to the key. If they forget, the edge serves a stale variant. The mitigation is process, not configuration — keep the allow-list in the same repository as the route handler, so adding req.query.currency to the code and adding currency to the key are one change set reviewed together. A deny-list has the mirror-image failure (a new tracking parameter quietly destroys your hit ratio) with none of the bounded-keyspace benefit.
Step 4 — Sort the survivors and fold case where it is safe
Two URLs that differ only in parameter order are the same request to every application ever written, and two different cache entries to every CDN by default. ?q=boots&page=2 and ?page=2&q=boots must hash identically or you have doubled your keyspace for free. Sorting is the cheapest normalization available and it is never wrong.
Case folding is not free and must be decided per parameter:
| Component | Fold case? | Reason |
|---|---|---|
| Parameter names | Yes | ?Page=2 and ?page=2 are the same intent; frameworks read them case-sensitively but visitors do not type them |
Enumerated values (sort=ASC) |
Yes | The value space is a handful of known tokens |
Locale values (lang=en-US) |
Yes, to a fixed set | Fold en-US, en-us, en_GB down to the locales you actually publish |
| Free-text search terms | No | ?q=Boots and ?q=boots may legitimately return different result ordering |
| Identifiers and hashes | No | Base64 and hex identifiers are case-significant; folding them breaks lookups |
| Signature parameters | Never | Any mutation invalidates the signature |
The rule of thumb: fold when the value comes from a closed set your code defines, never fold when the value comes from the user or from a cryptographic function.
Step 5 — Configure the edge
Cloudflare Cache Rules
Cloudflare expresses the whole of steps 2 through 4 declaratively. ignore_query_strings_order handles sorting, and query_string.include is the allow-list — anything not named is dropped from the key.
{
"description": "Search route: bounded query key",
"expression": "(starts_with(http.request.uri.path, \"/search\"))",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"cache_key": {
"ignore_query_strings_order": true,
"custom_key": {
"query_string": { "include": ["q", "page", "sort", "lang"] },
"header": { "include": ["accept-encoding"] },
"cookie": { "include": [] }
}
},
"edge_ttl": { "mode": "respect_origin" }
}
}
Note what is absent: there is no exclude list. That is the point — the moment you write "exclude": ["utm_source", ...] you have built a deny-list and inherited its unbounded keyspace. Cloudflare supports both forms; choose the include form deliberately.
CloudFront cache policy
CloudFront calls the allow-list whitelist, and the policy governs the key for every behavior it is attached to. Parameter order is normalized by CloudFront automatically once you use a whitelist.
resource "aws_cloudfront_cache_policy" "search" {
name = "search-bounded-query-key"
min_ttl = 0
default_ttl = 300
max_ttl = 86400
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
query_strings_config {
query_string_behavior = "whitelist"
query_strings { items = ["q", "page", "sort", "lang"] }
}
headers_config { header_behavior = "none" }
cookies_config { cookie_behavior = "none" }
}
}
# Keep the tracking params flowing to the origin without keying on them.
resource "aws_cloudfront_origin_request_policy" "search" {
name = "search-forward-all-query"
query_strings_config { query_string_behavior = "all" }
headers_config { header_behavior = "none" }
cookies_config { cookie_behavior = "none" }
}
The pairing is the important part. The cache policy decides the key; the origin request policy decides what reaches your server. Setting the first to whitelist and the second to all gives you a small key and a complete URL at the origin, which is exactly the split you want.
A Worker that rewrites the key
When the rule engine cannot express the logic — folding en-US to en, clamping page to a maximum, or dropping a parameter only on certain paths — do it in code. The Worker computes a canonical URL, caches under that, and fetches the origin with the original request.
const KEYED = ["q", "page", "sort", "lang"];
const LOCALES = new Set(["en", "de", "fr", "ja"]);
function canonicalize(rawUrl) {
const url = new URL(rawUrl);
const out = new URLSearchParams();
for (const name of KEYED) {
if (!url.searchParams.has(name)) continue;
let value = url.searchParams.get(name);
if (name === "sort") value = value.toLowerCase();
if (name === "lang") {
const base = value.toLowerCase().split(/[-_]/)[0];
value = LOCALES.has(base) ? base : "en";
}
if (name === "page") {
const n = Math.min(Math.max(parseInt(value, 10) || 1, 1), 50);
if (n === 1) continue; // page=1 is the same object as no page
value = String(n);
}
out.set(name, value);
}
// Sorting makes ?page=2&q=boots and ?q=boots&page=2 one entry.
out.sort();
url.search = out.toString();
return url.toString();
}
export default {
async fetch(request, env, ctx) {
if (request.method !== "GET") return fetch(request);
const cacheKey = new Request(canonicalize(request.url), { method: "GET" });
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) return response;
// Fetch with the ORIGINAL request so analytics still sees utm_* and fbclid.
response = await fetch(request);
response = new Response(response.body, response);
response.headers.set("x-cache-key", new URL(cacheKey.url).search || "(none)");
if (response.status === 200 && !response.headers.has("set-cookie")) {
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};
The x-cache-key debug header is the single most useful line in that file — it makes the verification step below trivial, and you can strip it once the rollout is stable. Deploy with npx wrangler deploy, and remember that this Worker now owns caching for the route, so Cache-Control set here outranks the origin. If you also rewrite paths at the edge, coordinate with rewriting URLs and paths at the edge so the two transformations run in a defined order.
Step 6 — Redirect or rewrite for canonical URLs?
Normalizing the key makes the cache efficient. It does nothing for the URL a visitor copies out of the address bar, which still carries ?utm_source=email&fbclid=IwAR.... Two mechanisms address that, and they are not interchangeable.
A rewrite is invisible: the edge serves the canonical object under whatever URL the visitor typed. One round trip, no address-bar change, tracking parameters remain available to client-side analytics. This is the default choice for pages that are entered from campaigns, because a redirect that strips utm_* before the analytics script runs destroys the attribution the campaign exists to measure.
A redirect is explicit: the edge answers 301 to the canonical URL, and the browser makes a second request. It collapses duplicate URLs in the visitor’s history and in anything that scrapes them, at the cost of an extra round trip and, if you strip tracking parameters, the attribution data.
| Situation | Use |
|---|---|
Campaign landing entered with utm_* |
Rewrite; keep the parameters for the analytics beacon |
| Parameter order differs between client SDKs | Rewrite; the key already collapses them |
Legacy parameter renamed (?p= became ?page=) |
Redirect once, then retire the old name |
Trailing ? or an empty value (?q=) |
Redirect to the bare path; it is a genuinely different URL to share |
Signed URL with sig and expires |
Neither; never touch a signed query string |
The practical compromise most sites land on: rewrite for caching, and emit a <link rel="canonical"> in the HTML pointing at the parameter-free URL. The edge gets one object, the visitor keeps their attribution, and anything that reads the page is told which URL is authoritative.
Verification
Verification has two halves: prove that two URLs now share one entry, and prove that the parameters you kept still split correctly.
Run the matrix. Every row states the URL, the expected cache status on the second request, and why.
BASE='https://shop.example.com/search'
probe() {
curl -sSD - -o /dev/null "$1" \
| grep -iE 'cf-cache-status|^x-cache:|^age:|^x-cache-key' | tr '\n' ' '
echo " <- $1"
}
# Warm the canonical object first.
probe "$BASE?q=boots&page=2"
# Same key, different spellings — all must HIT.
probe "$BASE?page=2&q=boots" # reordered
probe "$BASE?q=boots&page=2&utm_source=email" # tracking added
probe "$BASE?q=boots&page=2&fbclid=IwAR0xdeadbeef" # click id added
probe "$BASE?q=boots&page=2&zz=1" # unknown param
# Different key — must MISS on first request, then HIT.
probe "$BASE?q=boots&page=3"
probe "$BASE?q=boots&page=3"
Expected output:
cf-cache-status: MISS age: 0 x-cache-key: ?page=2&q=boots <- .../search?q=boots&page=2
cf-cache-status: HIT age: 4 x-cache-key: ?page=2&q=boots <- .../search?page=2&q=boots
cf-cache-status: HIT age: 5 x-cache-key: ?page=2&q=boots <- .../search?q=boots&page=2&utm_source=email
cf-cache-status: HIT age: 6 x-cache-key: ?page=2&q=boots <- .../search?q=boots&page=2&fbclid=IwAR0xdeadbeef
cf-cache-status: HIT age: 7 x-cache-key: ?page=2&q=boots <- .../search?q=boots&page=2&zz=1
cf-cache-status: MISS age: 0 x-cache-key: ?page=3&q=boots <- .../search?q=boots&page=3
cf-cache-status: HIT age: 2 x-cache-key: ?page=3&q=boots <- .../search?q=boots&page=3
The identical x-cache-key across the first five rows is the proof; the HIT statuses are the consequence. On CloudFront, substitute x-cache: Hit from cloudfront and compare x-amz-cf-id values from the same PoP rather than relying on a debug header.
For the hit-ratio half, take a reading before you deploy and another after a full cache cycle. Cloudflare exposes it through the GraphQL analytics API:
curl -s https://api.cloudflare.com/client/v4/graphql \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"query":"query{viewer{zones(filter:{zoneTag:\"'"$ZONE_ID"'\"}){httpRequests1hGroups(limit:24,orderBy:[datetimeHour_DESC]){dimensions{datetimeHour} sum{cachedRequests requests}}}}}"}' \
| python3 -c 'import sys,json
rows=json.load(sys.stdin)["data"]["viewer"]["zones"][0]["httpRequests1hGroups"]
for r in rows:
s=r["sum"]
print(r["dimensions"]["datetimeHour"], f"{s[\"cachedRequests\"]/max(s[\"requests\"],1):.1%}")'
A search route dominated by campaign traffic typically moves from the 30s into the high 80s within one full TTL cycle. If the number does not move, the objects were never cacheable in the first place — go back and check Cache-Control and Set-Cookie on the response, using customizing cache keys to improve hit ratio for the broader diagnostic path.
Troubleshooting
You dropped a parameter the application depends on
The symptom is the worst one in caching: correct-looking responses with wrong content. A visitor requests ?currency=EUR and receives the dollar prices someone else warmed the cache with. Confirm it by comparing bodies with the key bypassed:
curl -s "$BASE?q=boots¤cy=EUR" -H 'Cache-Control: no-cache' | md5sum
curl -s "$BASE?q=boots¤cy=USD" -H 'Cache-Control: no-cache' | md5sum
# Different hashes from origin, but one shared cache entry = the param is meaningful.
Fix it by adding the parameter to the allow-list and purging the affected paths — new key configuration does not evict objects stored under the old key, it only stops new requests from finding them. Then close the loop in Step 1: the parameter was missing from the inventory because the grep did not cover that handler.
Signed-URL parameters vanish from the key
Signature schemes put sig, expires, token or X-Amz-Signature in the query string, and these are both meaningful and access-controlling. If they fall outside the allow-list, every signed URL for an object collapses onto one cache entry, and a request with an expired or forged signature can be served the object that a valid signature warmed. Always list the signature parameters explicitly in the allow-list, never fold their case, never sort within their value, and never rewrite them. Verify with an intentionally expired signature — it must return your rejection response, not a cached body.
Pagination parameters produce a sprawl of one-hit entries
?page=1 through ?page=847 are all legitimately distinct, so they are all correctly keyed and almost all of them are requested once. That is not a normalization bug, but it does mean deep pages have no cache value. Two mitigations: canonicalize page=1 to the bare path as the Worker above does, since the two are the same object and the split is pure waste, and clamp the accepted range so ?page=99999 cannot mint arbitrary entries. Anything above the clamp should redirect or return an error rather than reach the origin.
Client SDKs send parameters in a different order
Mobile SDKs, server-side HTTP clients and browser fetch calls serialize maps in whatever order their language gives them, so the same logical request arrives spelled several ways. Detect it by grouping your logs on the sorted parameter set rather than the raw string:
awk '{print $7}' /var/log/nginx/access.log | grep -o '?.*' \
| awk -F'&' '{n=split($0,a,"&"); asort(a); s=""; for(i=1;i<=n;i++) s=s a[i]; print s}' \
| sort | uniq -c | sort -rn | head
If one sorted set has several raw spellings, ordering is fragmenting your cache. ignore_query_strings_order on Cloudflare, a whitelist on CloudFront, or out.sort() in the Worker all fix it; a deny-list on its own does not.
Cache poisoning through an unkeyed parameter the origin still reads
This is the failure mode that turns a hit-ratio optimization into a security incident. If a parameter is out of the cache key but the origin still reflects its value into the response — a redirect target, a canonical link, an error message, a <script src> — then any visitor can store their chosen value in the shared entry that everyone else receives.
The rule that closes it: any parameter the origin reads must either be in the cache key or be stripped from the request before it reaches the origin. Never leave a parameter in the forwarded request that is absent from the key unless you have confirmed the origin does nothing with it. Audit by diffing responses for a parameter you believe is inert:
curl -s "$BASE?q=boots" -H 'Cache-Control: no-cache' > /tmp/clean.html
curl -s "$BASE?q=boots&cb=CANARY123" -H 'Cache-Control: no-cache' > /tmp/probe.html
diff /tmp/clean.html /tmp/probe.html && echo "inert — safe to leave unkeyed"
grep -c 'CANARY123' /tmp/probe.html # any non-zero count is a poisoning vector
If the canary appears in the body, you have three options in order of preference: stop the origin reflecting it, add the parameter to the key, or strip it at the edge before forwarding. The same audit applies to headers such as X-Forwarded-Host, which are unkeyed by default on every provider. Note that the equivalent risk exists for content-hashed asset URLs handled through fingerprinting assets for immutable caching, where the hash is the key and any additional parameter is by definition unkeyed.
Frequently Asked Questions
Do dropped query parameters still reach my origin and my analytics?
Yes. The cache key is computed locally at the edge and does not change the request that is forwarded. Cloudflare and Fastly send the original URL by default, and on CloudFront you pair a whitelist cache policy with an all origin request policy to get the same result. Client-side analytics is untouched because the browser’s address bar never changes.
Why is an allow-list safer than a deny-list if a forgotten parameter serves wrong content? Because you can enumerate what your application reads, and you cannot enumerate what the world will append. A deny-list leaves the keyspace unbounded, so anyone can mint infinite cache keys and push every request through to your origin. The allow-list’s failure mode is a known parameter someone forgot to add, which code review catches; the deny-list’s failure mode is an unknown parameter nobody can anticipate.
Should I sort parameters or lowercase them first? Sort first, then fold case, and fold only where the value comes from a closed set your code defines. Sorting is always safe and removes the largest source of duplicate entries. Case folding a free-text search term or a base64 identifier changes meaning, so it must be decided per parameter rather than applied to the whole string.
What do I do with signed URLs that carry sig and expires?
Keep both parameters in the cache key exactly as sent, and never sort within their values, fold their case, or rewrite them. Dropping a signature parameter from the key lets an expired or invalid signature be served the object that a valid one warmed, which converts a caching change into an access-control bypass.
Does changing the cache key purge anything? No. Objects stored under the old key stay in edge storage until their TTL expires; they simply become unreachable because no incoming request now hashes to them. Expect a temporary rise in origin traffic while the new keyspace warms, and issue an explicit purge when the change was made to correct wrong content rather than to improve hit ratio.
Related
- Customizing Cache Keys to Improve Hit Ratio
- CDN TTL Behavior on Cloudflare, Fastly and CloudFront
- Rewriting URLs and Paths at the Edge
- Fingerprinting Assets for Immutable Caching
Back to Cache Key & Vary Configuration