Short-Lived Signed URLs and CDN Cache Hit Rate

A 15-minute signature is a security decision. A 15-minute signature inside the cache key is an infrastructure decision, and it is usually made by accident. The moment the exp and sig parameters participate in the stored object’s identity, two viewers of the same photo produce two different cache keys, the edge stores two copies of identical bytes, and your CDN degrades into an expensive reverse proxy. This page is the arithmetic of that failure and the exact repair — expiry quantization with a grace window — for the case where you cannot remove the parameters from the key. The signing mechanics themselves live in Signed URLs and Media Access Control; this is about the second-order cost of the TTL you choose.

The distinction that makes the whole problem tractable: signature lifetime and cache-key entropy are independent variables, and most teams have accidentally coupled them. If you can decouple them (strip the parameters before the key is computed) TTL becomes free and you can sign for 60 seconds if you like. If you cannot — a managed image pipeline, a third-party player, a CDN plan without custom cache keys — then quantization gives you back most of the hit ratio without touching the security model.

Prerequisite checklist

Where the entropy actually enters

The cache key is a hash over a normalized request. Everything you allow into that normalization multiplies your working set. The two pipelines below differ by three lines of Worker code and by a factor of nearly 100 in stored objects.

Cache key derivation with and without signature parameters Two parallel pipelines start from the same signed media request. The left pipeline uses the default cache key, which includes the whole query string, so the object identity changes every time the expiry changes and the asset accumulates 96 objects per day at a 60 percent hit ratio. The right pipeline copies the URL, deletes the exp, sig and kid parameters, keeps only format-relevant parameters, and produces one stable object per asset per format at a 99.1 percent hit ratio. Default cache key Signature-free cache key GET /v/hero.mp4?w=1280&exp=1785000900 &sig=9c1f…&kid=k1 GET /v/hero.mp4?w=1280&exp=1785000900 &sig=9c1f…&kid=k1 key = host + path + entire query auth params kept verbatim verify first, then delete exp, sig, kid keep w, f and the normalized Accept object id changes every 15 minutes and again for every distinct signer object id stable for the whole TTL one entry per width and format variant 96 objects per asset per day hit ratio 60 % · origin egress ×7 1 object per asset per variant hit ratio 99.1 % · origin egress ×1 Both pipelines verify the same signature. Only the bytes fed into the key hash differ. Deleting the parameters after verification is safe: the proof has already been checked.

The arithmetic

Let R be requests per asset per day and B the expiry granularity in minutes. If the expiry is in the cache key, the number of distinct objects created per asset per day is min(R, 1440 / B), and every one of them costs exactly one origin fetch. Hit ratio is therefore:

H = 1 − min(R, 1440 / B) / R

With a raw per-request expiry, B is effectively the clock resolution — one second — so 1440/B is 86 400, far larger than any realistic R, and H collapses to zero. Quantize to B = 15 minutes and you create 96 objects per day; at R = 240 that is a 60 % hit ratio. Quantize to B = 240 minutes and you create 6 objects; the same R yields 97.5 %.

Bucket B Objects/asset/day Hit ratio at R=240 Hit ratio at R=2 400 Max signature lifetime
per request ≈ R 0 % 0 % exact
1 min 1 440 0 % 40.0 % 1–2 min
5 min 288 0 % 88.0 % 5–10 min
15 min 96 60.0 % 96.0 % 15–25 min
30 min 48 80.0 % 98.0 % 30–40 min
60 min 24 90.0 % 99.0 % 60–70 min
4 h 6 97.5 % 99.75 % 4 h+
stripped from key 1 99.6 % 99.96 % any

The shape of that curve is the whole design conversation. Notice that the “hot” column (R = 2 400, a front-page asset) tolerates a 5-minute bucket comfortably, while the cold column (R = 240, an archive asset) needs at least 30 minutes before the numbers stop being embarrassing. Real libraries contain both, which is why a single global bucket is always a compromise and why stripping the parameters — when you can — beats every bucket size.

Edge hit ratio versus expiry bucket window A rising curve plots edge hit ratio against expiry bucket size for an asset receiving 240 requests per day with the expiry inside the cache key: 2.4 percent at a one minute bucket, 12.5 at five minutes, 60 at fifteen minutes, 80 at thirty minutes, 90 at sixty minutes, 97.5 at four hours and 99.6 at twenty four hours. A flat reference line at 99.1 percent shows the result when the signature parameters are removed from the cache key entirely, which is independent of bucket size. Edge hit ratio vs expiry bucket size (expiry inside the cache key, R = 240/day) 100 75 50 25 0 signature stripped from key — 99.1 %, bucket size irrelevant 2.4 12.5 60.0 80.0 90.0 97.5 99.6 1 min 5 min 15 min 30 min 60 min 4 h 24 h expiry granularity (bucket window) Every point on the curve buys hit ratio with signature lifetime; the dashed line buys it with three lines of edge code. Hotter assets (higher R) shift the whole curve upward — the same bucket performs better on popular objects.

The complete solution: quantized expiry with a grace window

The signer below rounds the expiry up to the next window boundary and then adds a fixed grace period. Rounding alone is not enough: a user who arrives one second before a boundary would receive a URL with one second of life left. The grace term is what turns a sawtooth that touches zero into one with a guaranteed floor.

// bucketed-signer.mjs — Node 20+. Mints signed media URLs that are byte-identical
// for every viewer inside the same window, so the edge stores one object per window.
import { createHmac, timingSafeEqual } from 'node:crypto';

export const WINDOW = 900;   // 15 min. THE cache-key knob: objects/asset/day = 86400 / WINDOW.
export const GRACE  = 600;   // 10 min. Minimum life any URL is guaranteed, no matter when minted.
const KID           = 'k2';  // active key id; the verifier keeps k1 accepted during rotation.

// Round UP to the next window boundary, then extend by GRACE.
// Using Math.floor(now / WINDOW) * WINDOW + WINDOW (not Math.ceil) keeps the result
// stable when `now` lands exactly on a boundary — Math.ceil would return `now` itself
// and hand that unlucky viewer a URL with only GRACE seconds of life.
export function bucketedExpiry(nowSeconds = Math.floor(Date.now() / 1000)) {
  return Math.floor(nowSeconds / WINDOW) * WINDOW + WINDOW + GRACE;
}

// base64url, unpadded: '+' and '/' in a query string get percent-encoded by some
// players and then fail verification at the edge. '=' padding does the same.
const b64url = (buf) => buf.toString('base64')
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

export function signMediaUrl(pathname, { variantQuery = '' } = {}) {
  const exp = bucketedExpiry();

  // Canonical string = path + "\n" + exp. Deliberately NOT the full query string:
  // players append cache-busters to range requests, and any such mutation would
  // otherwise invalidate a token that is still perfectly valid.
  const mac = createHmac('sha256', process.env.SIGNING_KEY_K2)
    .update(`${pathname}\n${exp}`)
    .digest();

  const qs = new URLSearchParams(variantQuery);   // w=, f=, q= — variant params stay in the key
  qs.set('exp', String(exp));                     // identical for every viewer in this window
  qs.set('sig', b64url(mac));                     // therefore identical too
  qs.set('kid', KID);                             // constant between rotations
  return `https://media.example.com${pathname}?${qs.toString()}`;
}

// Verifier — this is the half that must agree, byte for byte, with the signer.
export function verify(pathname, exp, sig, secret, skew = 30) {
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isFinite(exp) || now > exp + skew) return false;   // skew tolerance in seconds
  // Reject an expiry further out than one window + grace + skew: this stops a
  // leaked signer or a buggy client from minting effectively permanent URLs.
  if (exp > now + WINDOW + GRACE + skew) return false;
  const expected = createHmac('sha256', secret).update(`${pathname}\n${exp}`).digest();
  const given = Buffer.from(sig.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
  // Length check first: timingSafeEqual throws on a length mismatch.
  return expected.length === given.length && timingSafeEqual(expected, given);
}

// The property the cache depends on: two signers, two moments, one URL.
const a = signMediaUrl('/v/hero.mp4', { variantQuery: 'w=1280' });
const b = signMediaUrl('/v/hero.mp4', { variantQuery: 'w=1280' });
console.assert(a === b, 'URLs must be identical inside a window');

Warning: WINDOW must divide 86 400 evenly (60, 300, 900, 1800, 3600, 14400 all do) or your bucket boundaries drift relative to the day and you get one short bucket every 24 hours — a daily hit-ratio dip that looks like a mysterious traffic pattern.

For CloudFront the same idea applies to the canned-policy Expires field: set it to bucketedExpiry() rather than now + ttl. CloudFront already excludes Expires from the cache key, so quantization there is belt-and-braces — useful only if a Lambda@Edge or CloudFront Function has re-introduced the parameter into the key, which happens more often than it should.

Expiry quantization: many arrivals, one expiry value per window A timeline from 12:00 to 13:20 divided into four twenty-minute windows. Three viewers arriving at different moments inside the first window all receive the same expiry, the boundary at 12:20 plus a grace extension. Two viewers in the second window share the 12:40 boundary, and one viewer in the third shares the 13:00 boundary. Because the expiry is identical, the signature and therefore the whole URL are identical, so each window corresponds to exactly one cached object. Arrivals snap to the next window boundary → one URL, one cached object per window boundary T1 boundary T2 boundary T3 u1 + grace u2 u3 u4 u5 u6 exp = T1 + grace for u1, u2, u3 exp = T2 + grace for u4, u5 window A 1 cached object window B 1 cached object window C 1 cached object window D not yet minted 12:00 12:20 12:40 13:00 13:20 Identical exp → identical HMAC → identical URL string → identical cache key. Nothing user-specific ever enters the key. Set the edge object TTL to roughly one window plus grace so superseded objects evict instead of accumulating.

Why the grace window is not optional

Without grace, remaining validity at issue time is WINDOW − (now mod WINDOW) — a sawtooth that runs from a full window down to literally zero at each boundary. A viewer who loads a gallery two seconds before 12:20 receives sixty thumbnail URLs that all expire in two seconds. On a slow mobile connection the first eight images load and the remaining fifty-two return 403. Adding GRACE lifts the entire sawtooth so its trough equals the grace value.

Remaining validity at issue time, with and without grace Two sawtooth traces over three consecutive twenty-minute windows. Without grace, remaining validity falls linearly from twenty minutes to zero at each boundary, entering a shaded danger band below two minutes just before every boundary. With a ten-minute grace window the same sawtooth is lifted so it falls only from thirty minutes to ten and never enters the danger band. Remaining validity at issue time — 20 min window, with and without a 10 min grace y axis: minutes of life left on a URL minted at that instant under 2 min left — slow gallery loads start 403-ing 30 20 10 0 0 20 40 60 minutes elapsed boundary rounding only — trough reaches 0 rounding + 10 min grace — trough is 10 min Grace costs nothing in cache terms: it is a constant added to every expiry, so the URL stays identical within a window.

Tradeoff: grace extends the maximum useful life of a leaked URL to WINDOW + GRACE. With the values above that is 25 minutes rather than 15. If your threat model cannot absorb ten extra minutes, the answer is not a smaller grace — it is a signed cookie or a bearer token, both of which decouple lifetime from the URL entirely.

Verification

1. Prove the URLs are byte-identical inside a window

# Mint the same asset at four simulated instants spread across one window and
# confirm the signer emits one distinct string, not four.
for OFFSET in 0 137 411 892; do
  node -e "
    process.env.MOCK_NOW = ${OFFSET};
    const { signMediaUrl } = await import('./bucketed-signer.mjs');
    console.log(signMediaUrl('/v/hero.mp4', { variantQuery: 'w=1280' }));
  " --input-type=module
done | sort -u | wc -l
# 1   <- correct. Anything above 1 means a per-request value is still leaking in.

2. Prove two independently minted URLs hit the same object

A=$(node -e "import('./bucketed-signer.mjs').then(m=>console.log(m.signMediaUrl('/v/hero.mp4')))" --input-type=module)
B=$(node -e "import('./bucketed-signer.mjs').then(m=>console.log(m.signMediaUrl('/v/hero.mp4')))" --input-type=module)

curl -sI "$A" | grep -iE 'cf-cache-status|age|x-cache'
curl -sI "$B" | grep -iE 'cf-cache-status|age|x-cache'
# cf-cache-status: HIT ; age: 812
# cf-cache-status: HIT ; age: 812      <- identical age => genuinely the same object
#
# Two MISSes with age: 0 mean the signature is still in the cache key. Fix the key,
# not the TTL — see the parent guide's Worker for the three lines that strip it.

Warning: a single warm PoP will happily report HIT for both requests even when the key is wrong, if both requests were served by the same edge server within the same second. Compare the age values, not just the status, and repeat from two different network locations before believing the result.

3. Read the hit ratio from the provider, not from curl

# Cloudflare — hit/miss split for one hostname over 24 h.
curl -s https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer ${CF_API_TOKEN}" -H 'Content-Type: application/json' \
  --data '{"query":"{ viewer { zones(filter:{zoneTag:\"'"${ZONE_ID}"'\"}) {
      httpRequestsAdaptiveGroups(limit:10, filter:{
        datetime_geq:\"2026-07-30T00:00:00Z\", clientRequestHTTPHost:\"media.example.com\"})
      { count dimensions { cacheStatus } } } } }"}' \
  | jq -r '.data.viewer.zones[0].httpRequestsAdaptiveGroups[]
           | "\(.dimensions.cacheStatus)\t\(.count)"'
# hit     9412883
# miss     84120      <- 99.1 %

# CloudFront — the same question as a CloudWatch metric.
aws cloudwatch get-metric-statistics --namespace AWS/CloudFront \
  --metric-name CacheHitRate --dimensions Name=DistributionId,Value=E1ABCDEF \
  Name=Region,Value=Global --statistics Average \
  --start-time "$(date -u -d '-2 day' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 3600 --query 'Datapoints[].[Timestamp,Average]' --output text | sort

Plot the result against your deploy timeline. A hit-ratio step change that lines up with a release is nearly always a new query parameter entering the key; the isolation procedure is in debugging CloudFront cache misses for images, and wiring an alert so the regression cannot survive a week is covered under monitoring and regression for media delivery.

4. Assert the validity floor in CI

// vitest / node:test — fails the build if any minting instant would produce a
// URL with less than 8 minutes of life. Guards against someone "tidying up" GRACE.
import { WINDOW, GRACE, bucketedExpiry } from './bucketed-signer.mjs';

const FLOOR = 480;                       // 8 min, the p99 gallery load time on 3G
let worst = Infinity;
for (let t = 0; t < WINDOW; t += 1) {    // sweep every second of one window
  worst = Math.min(worst, bucketedExpiry(t) - t);
}
if (worst < FLOOR) throw new Error(`validity floor ${worst}s < ${FLOOR}s — raise GRACE`);
console.log(`min remaining validity ${worst}s (expected ${GRACE + 1}s)`);

Common mistakes and their corrected form

1. Quantizing the expiry while a user id still varies

// WRONG: exp is bucketed, but uid is per-user — the cache key still fragments
// exactly as badly as before, and now you have a longer-lived credential too.
`/v/hero.mp4?uid=${user.id}&exp=${bucketedExpiry()}&sig=${sig}`

// CORRECT: user identity belongs in the signed message, never in the URL.
// The edge learns nothing about the user; the key stays shared.
const sig = hmac(`${pathname}\n${exp}\n${user.tier}`);   // tier, not id — a handful of values
`/v/hero.mp4?exp=${exp}&sig=${sig}&kid=k2`

If access genuinely differs per user rather than per tier, the URL is the wrong carrier — use a signed cookie so the credential never touches the cache key at all.

2. Rounding down instead of up

Math.floor((now + TTL) / WINDOW) * WINDOW looks equivalent and is not: it can return a boundary already in the past, producing URLs that are expired at birth for anyone minting in the first seconds of a window. Always round the boundary up and add grace, exactly as bucketedExpiry() does.

3. Leaving the object TTL far longer than the window

With exp in the key and max-age=2592000, every superseded bucket object sits in the edge cache for thirty days doing nothing except consuming the PoP’s LRU budget and evicting objects that are still live. Set the edge TTL to roughly WINDOW + GRACE for keyed-signature deployments, and keep the long max-age only once the parameters are out of the key. The reasoning behind both TTL choices is set out in best practices for setting max-age on CDN media assets.

4. Using stale-while-revalidate to hide the misses

Warning: stale-while-revalidate masks latency, not cost. A fragmented key still produces a distinct object per bucket, so there is no stale copy to serve for a key the edge has never seen — the directive only helps for a key that already exists. Origin egress is unchanged, and the metric you were watching (p95 latency) improves just enough to stop anyone investigating. Fix the key first; then stale-while-revalidate for media assets is genuinely useful for smoothing revalidation at bucket rollover.

5. Ignoring the format-negotiation dimension

If your edge also varies on Accept to serve AVIF or WebP — the pattern in CloudFront cache policy for Vary: Accept negotiation and configuring Cloudflare Image Resizing URL parameters — then object count multiplies: buckets × formats × widths. A 15-minute bucket, three formats and four widths is 1 152 objects per asset per day. Normalize Accept down to two or three values before it reaches the key, and treat the bucket count as the factor you multiply everything else by.

6. Shortening the TTL as a response to a leak

Shortening a signature from 15 minutes to 60 seconds multiplies your object count by 15 and reduces the leak window by 14 minutes. That is a poor exchange, because the realistic leak vectors — a screenshot of the URL, a referrer header, a shared browser profile — are all exploited in seconds, not minutes. Bind the signature to something the attacker cannot replicate (a path prefix, a coarse IP range, a session cookie) rather than shrinking the clock.