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.
Query string normalization pipeline A raw query string passes through tracking removal, an allow-list filter, and sorting with case folding, producing a single canonical cache key. From raw query string to one canonical key Raw query ?b=2&utm_id=9 &a=1&FBCLID=z 4 params Drop tracking utm_*, fbclid, gclid, mc_eid 2 params left Allow-list keep a, b only unknown = drop bounded set Sort + fold stable order safe lowercasing deterministic Cache key: /search?a=1&b=2 every campaign variant of that URL now collapses onto it

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.

Allow-list versus deny-list keyspace Side by side comparison showing an allow-list holding the keyspace to a fixed set of parameters while a deny-list lets any unknown parameter create a new cache entry. An unknown parameter arrives: what happens to the keyspace Allow-list key = q, page, sort only ?zz=1 ignored, request HITs ?zz=2 ignored, request HITs ?newtag=x ignored, HITs keyspace stays bounded Cost: a new meaningful param needs an explicit key change, shipped in the same change set Deny-list key = everything but utm_* ?zz=1 keyed, MISS to origin ?zz=2 keyed, MISS to origin ?zz=3 keyed, MISS to origin keyspace is unbounded Cost: anyone can mint infinite keys and drive every request straight through to the origin

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&currency=EUR" -H 'Cache-Control: no-cache' | md5sum
curl -s "$BASE?q=boots&currency=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.

Cache poisoning through an unkeyed parameter An attacker sends a request with a parameter excluded from the cache key. The origin reflects that value into the body, and the edge stores the poisoned response under the clean key that every other visitor requests. Unkeyed input the origin still reads Attacker appends ?cb=evil CDN edge cb not in the key Origin reflects cb in body GET /p?lang=en&cb=evil full URL forwarded body contains evil stored under key /p?lang=en Every later visitor requesting /p?lang=en is served the attacker's body.

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.

Back to Cache Key & Vary Configuration