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.
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.
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.
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.
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.
Related
- Signed URLs and Media Access Control — the parent guide covering signature construction, carriers, key rotation and clock skew
- Hotlink Protection with Referer and Token Auth — the edge guard that verifies these tokens without adding cache-key entropy
- Best Practices for Setting max-age on CDN Media Assets — choosing the object TTL that sits underneath the bucket window
- Debugging CloudFront Cache Misses for Images — finding the parameter that crept into the cache key
- Monitoring and Regression for Media Delivery — alerting on hit-ratio regressions before they become an invoice