Signed URLs and Media Access Control
A signed media URL is a bearer credential that happens to look like a file path. It carries its own authorization proof — a MAC or a digital signature over a canonical string — so the edge can grant or deny the request without ever talking to your application. That property is what makes it viable at CDN scale, and it is also what makes it dangerous: anyone holding the URL holds the access. This guide sits under CDN & Edge Media Delivery and covers the mechanics that decide whether a signing scheme protects your library or quietly destroys your edge cache — signature construction on CloudFront and Cloudflare, referer-based hotlink rules, signed cookies for multi-asset sessions, key rotation, clock skew, and the measurable cache cost of putting a per-user string into a shared cache key.
The three failure modes worth internalising before you write any code: signatures that are trivially replayable because the policy pins nothing but an expiry; signatures that end up inside the cache key and drop edge hit ratio from 99% to single digits; and key rotations that invalidate URLs already in flight because the overlap window was shorter than the longest signed lifetime. Everything below is organised around avoiding those three.
Concept and Architecture
Every signing scheme in production use is the same four-part construction. You build a canonical string (the resource path, an expiry, and optional conditions), compute a proof over it with a secret, attach the proof plus a key identifier to the request, and the edge recomputes the proof and compares. The variation between vendors is only in which bytes go into the canonical string, which algorithm produces the proof, and where the proof rides — query string, cookie, or header.
CloudFront uses asymmetric signing: your origin holds a 2048-bit RSA private key, the distribution references a trusted key group holding the matching public keys, and the proof is RSA-SHA1 over a JSON policy document. Asymmetry matters operationally — the edge never holds a secret that can mint URLs, so a compromised PoP configuration cannot be used to forge access. Cloudflare’s token authentication, by contrast, is symmetric HMAC-SHA256 over a URL-derived message, with the shared secret either stored in a Rules expression or held in a Worker secret binding. HMAC is roughly 500× cheaper to compute (about 2 µs versus 1.1 ms for a 2048-bit RSA sign on one core), which matters when you are minting thousands of URLs per second for a paginated gallery.
The critical architectural detail — the one that determines your cache economics — is whether the signature parameters participate in the cache key. CloudFront strips its four reserved query parameters (Expires, Signature, Key-Pair-Id, Policy) from the cache key automatically, so two users with different signatures for the same object hit the same cached bytes. Cloudflare has no reserved names: if you sign with ?exp=…&sig=… and leave the default cache key in place, every signature is a distinct cached object. That single configuration difference is the whole subject of short-lived signed URLs and CDN cache hit rate.
The flow below traces one signed request from the signer through edge verification to either a denial or a cache lookup. Note that verification happens before cache lookup, and that the cache key is computed from the URL with the signature parameters removed.
The four proof carriers
Query-string signature. The proof lives in the URL. Universally supported, survives redirects, works for <img> and <video> alike, and is the only option when the media element cannot send credentials. The cost is that the URL is the credential: it leaks into referrer headers, browser history, proxy logs, and any analytics pipeline that captures full URLs.
Signed cookie. The proof lives in a Set-Cookie header scoped to a path prefix. One issuance covers hundreds of subsequent requests, which is the correct shape for HLS or DASH where a single session pulls a manifest plus several hundred segments. It requires same-site or properly configured cross-site cookie attributes, and it does not survive a <video> element pointed at a different registrable domain without crossorigin plus SameSite=None; Secure.
Bearer header. The proof lives in Authorization or a custom header. Cleanest from a leakage standpoint, but native media elements will not send it — you need a Service Worker, a fetch() + MediaSource pipeline, or a player that supports request interceptors.
Referer allowlist. Not a proof at all, but a filter: the edge compares Referer against an allowlist and rejects everything else. It costs nothing, adds zero cache-key entropy, and stops casual hotlinking from forums and scraper sites. It is trivially spoofable with curl -H 'Referer: …', so it belongs strictly as an outer layer in front of a real signature. The combination pattern is covered in hotlink protection with referer and token auth.
Benchmark and Specification Table
The table below compares the mechanisms across the platforms most media teams actually deploy on. Sign cost is measured on a single vCPU (Graviton3, Node 22) minting 100 000 URLs; verify cost is the edge-side figure the vendor documents or that a Worker benchmark reports.
| Mechanism | Algorithm | Sign cost | Verify cost | Max lifetime | Sig in cache key by default | Multi-asset scope |
|---|---|---|---|---|---|---|
| CloudFront signed URL (canned policy) | RSA-SHA1, 2048-bit | 1.10 ms | edge-native, ~0 | unbounded (Expires epoch) |
No — reserved params stripped | one exact URL |
| CloudFront signed URL (custom policy) | RSA-SHA1, 2048-bit | 1.14 ms | edge-native, ~0 | unbounded | No | wildcard path, IP CIDR, date range |
| CloudFront signed cookie | RSA-SHA1, 2048-bit | 1.14 ms | edge-native, ~0 | cookie lifetime | No — cookies excluded unless forwarded | wildcard path prefix |
| Cloudflare Rules token auth | HMAC-SHA256 | 2.1 µs | ~0.05 ms in ruleset | max_age argument, ≤ 31 days practical |
Yes — must be stripped explicitly | one exact path |
| Cloudflare Worker HMAC | HMAC-SHA256 (Web Crypto) | 2.1 µs | 0.18 ms per request | your choice | Yes unless you build a custom cache key | anything you encode |
| Cloudflare Images signed URL | HMAC-SHA256 over path?exp= |
2.4 µs | edge-native | exp epoch |
No — handled by the Images pipeline | one variant URL |
| Referer allowlist (WAF rule) | none | 0 | ~0.02 ms | n/a | No | whole path pattern |
| Ed25519 detached token | Ed25519 | 48 µs | 0.11 ms in a Worker | your choice | depends on carrier | anything you encode |
Two numbers drive most design decisions here. First, RSA signing at 1.1 ms means a gallery page rendering 60 signed thumbnails costs 66 ms of CPU on the signer — enough to justify batching into a single custom policy with a wildcard Resource instead of 60 individual signatures. Second, HMAC at 2.1 µs means the signing cost is irrelevant and the only thing you should optimise for is cache-key shape.
What per-user signatures cost the edge cache
The following figures come from a 10 M requests/day media distribution with a 180 KB mean object size (1.80 TB/day of egress if every request went to origin), 42 000 daily active users, and a 30-day object TTL. Only the signing scheme changes between rows.
| Signing scheme | Distinct cache objects per asset | Edge hit ratio | Origin egress/day |
|---|---|---|---|
| Unsigned URL (baseline) | 1 | 99.2 % | 14.4 GB |
| Signed URL, params excluded from key | 1 | 99.1 % | 16.2 GB |
| Signed cookie, one issuance per session | 1 | 99.0 % | 18.0 GB |
| Signed URL, expiry bucketed to the hour, shared across users | 24 | 97.6 % | 43.2 GB |
| Signed URL, per-user 15-minute signature inside the key | ~4 000/day | 4.1 % | 1.73 TB |
The last row is not a rounding error. A per-user signature inside the cache key converts a CDN into a very expensive reverse proxy: origin egress rises 120× and p95 latency roughly triples because nearly every request pays a full origin round trip. The chart makes the cliff visible.
Step-by-Step Implementation
Step 1 — Mint CloudFront custom-policy signed URLs
A canned policy signs one exact URL and one expiry. A custom policy signs a JSON document and unlocks wildcards, a start time, and a source-IP condition — which is what you want for anything with more than one file. The signer below produces both the URL and the reusable policy so a gallery of 60 thumbnails costs one RSA operation instead of 60.
// signer.mjs — CloudFront custom-policy signed URL. Node 20+.
import { createSign } from 'node:crypto';
const KEY_PAIR_ID = process.env.CF_KEY_PAIR_ID; // e.g. "K2JCJMDEHXQW6F" — the public key ID inside the trusted key group
const PRIVATE_KEY = process.env.CF_PRIVATE_KEY; // 2048-bit RSA PEM; lives only on the signer, never at the edge
// CloudFront uses a NON-standard URL-safe base64 alphabet. Getting this wrong
// produces signatures that verify locally and 403 at the edge.
const cfB64 = (buf) =>
buf.toString('base64').replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
export function signMediaUrl(resource, opts = {}) {
const {
ttlSeconds = 900, // 15 min: long enough for a slow mobile start, short enough to limit sharing
sourceIp = null, // CIDR, e.g. "203.0.113.4/32" — breaks under CGNAT and mobile handoff
notBeforeSkew = 60, // backdate the start so a signer clock running fast cannot emit unusable URLs
} = opts;
const now = Math.floor(Date.now() / 1000); // ALWAYS epoch-UTC; local time here is the classic skew bug
const condition = {
DateLessThan: { 'AWS:EpochTime': now + ttlSeconds }, // the only mandatory condition
DateGreaterThan: { 'AWS:EpochTime': now - notBeforeSkew },
};
if (sourceIp) condition.IpAddress = { 'AWS:SourceIp': sourceIp };
// `resource` may contain a single trailing wildcard: one signature covers a whole prefix.
const policy = JSON.stringify({ Statement: [{ Resource: resource, Condition: condition }] });
const signature = createSign('RSA-SHA1').update(policy).sign(PRIVATE_KEY);
const qs = new URLSearchParams();
qs.set('Policy', cfB64(Buffer.from(policy)));
qs.set('Signature', cfB64(signature));
qs.set('Key-Pair-Id', KEY_PAIR_ID);
// URLSearchParams percent-encodes '~' and '_' safely; CloudFront decodes before verifying.
return { policyQuery: qs.toString(), expiresAt: now + ttlSeconds };
}
// One signature, sixty URLs — the wildcard resource is what makes this cheap.
const { policyQuery } = signMediaUrl('https://media.example.com/gallery/2026-07/*', { ttlSeconds: 1800 });
const urls = Array.from({ length: 60 }, (_, i) =>
`https://media.example.com/gallery/2026-07/thumb-${i}.avif?${policyQuery}`);
console.log(urls[0]);
The full CloudFront path — key group creation, TrustedKeyGroups on the cache behavior, and origin access control so S3 refuses direct reads — is walked end to end in generating CloudFront signed URLs for private media.
Step 2 — Verify an HMAC token in a Cloudflare Worker
Cloudflare’s declarative is_timed_hmac_validate_v0() rules function is enough for a single path pattern, but a Worker gives you key IDs, skew control, and — crucially — a custom cache key that keeps the signature out of the stored object identity.
// worker.js — HMAC-SHA256 token verification with a signature-free cache key.
const KEYS = (env) => ({ k1: env.SIGNING_KEY_K1, k2: env.SIGNING_KEY_K2 }); // two live keys for rotation
async function hmacHex(secret, message) {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2, '0')).join('');
}
// Constant-time compare. A naive === leaks byte position through timing on hot paths.
function safeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const exp = Number(url.searchParams.get('exp'));
const sig = url.searchParams.get('sig') ?? '';
const kid = url.searchParams.get('kid') ?? 'k1';
const secret = KEYS(env)[kid];
const SKEW = 30; // seconds of tolerance for signer/edge clock drift in BOTH directions
const now = Math.floor(Date.now() / 1000);
if (!secret || !exp || now > exp + SKEW) {
return new Response('expired or unknown key', { status: 403 });
}
// Canonical string binds the signature to path AND expiry. Omitting the path
// lets an attacker move a valid signature onto any other object.
const expected = await hmacHex(secret, `${url.pathname}\n${exp}`);
if (!safeEqual(expected, sig)) return new Response('bad signature', { status: 403 });
// Cache key with all auth params removed — this is what preserves the hit ratio.
const cacheUrl = new URL(url);
['exp', 'sig', 'kid'].forEach(p => cacheUrl.searchParams.delete(p));
const cacheKey = new Request(cacheUrl.toString(), { method: 'GET' });
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
response = await fetch(cacheUrl.toString(), { cf: { cacheEverything: true, cacheTtl: 2592000 } });
response = new Response(response.body, response);
response.headers.set('Cache-Control', 'public, max-age=2592000, immutable');
// Vary must NOT include anything user-specific, or you refragment what you just unified.
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};
Warning: the response you hand back is a shared cached object. Never set Cache-Control: private conditionally, and never echo the user ID into a response header — the first user’s headers become every user’s headers.
Step 3 — Add a referer allowlist as the outer layer
Referer filtering is free and stops the bulk of hotlinking traffic before signature verification ever runs. Deploy it as a Cloudflare custom rule (or a CloudFront Function) and always allow the empty referer, because browsers legitimately omit it under Referrer-Policy: no-referrer and for <video> element ranged requests in some engines.
# Cloudflare custom rule: block cross-site media hotlinks, allow empty referer,
# then let the Worker/token layer make the real authorization decision.
curl -sX POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/${RULESET_ID}/rules" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H 'Content-Type: application/json' \
--data '{
"action": "block",
"description": "hotlink guard for /media/*",
"expression": "(http.request.uri.path matches \"^/media/\") and (http.referer ne \"\") and not (http.referer contains \"example.com\") and not (http.referer contains \"cdn.partner.net\")"
}'
# Verify: a spoofed referer still passes this layer — which is exactly why it is not the last layer.
curl -sI -H 'Referer: https://scraper.invalid/' https://media.example.com/media/hero.mp4 | head -1
# HTTP/2 403
curl -sI -H 'Referer: https://example.com/gallery' https://media.example.com/media/hero.mp4 | head -1
# HTTP/2 200 (still requires a valid token behind it)
Tradeoff: every referer you allowlist is a domain whose pages can embed your media for free. Partner allowlists grow silently; review them on the same cadence as key rotation.
Step 4 — Issue signed cookies for a multi-asset session
An HLS ladder is one manifest plus several hundred segments. Signing each segment URL means one RSA operation per segment and a fresh credential in every playlist line; signing a cookie once covers the whole prefix for the session’s lifetime.
// session.mjs — Express handler issuing CloudFront signed cookies for one playback session.
import { createSign } from 'node:crypto';
const cfB64 = (b) => b.toString('base64').replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
app.post('/api/playback-session', requireAuthenticatedUser, (req, res) => {
const sessionId = crypto.randomUUID();
const prefix = `https://media.example.com/hls/${sessionId}/*`; // per-session prefix, not per-user
const now = Math.floor(Date.now() / 1000);
const expires = now + 4 * 3600; // must exceed the longest plausible watch session, incl. pauses
const policy = JSON.stringify({
Statement: [{ Resource: prefix, Condition: { DateLessThan: { 'AWS:EpochTime': expires } } }],
});
const signature = createSign('RSA-SHA1').update(policy).sign(process.env.CF_PRIVATE_KEY);
const cookieOpts = {
domain: '.example.com', // must cover BOTH the page host and the media host
path: `/hls/${sessionId}/`, // narrow path stops the cookie riding along on unrelated requests
secure: true,
httpOnly: true, // the player never needs to read these; JS access is pure risk
sameSite: 'none', // required when media.example.com differs from the page origin
maxAge: (expires - now) * 1000,
};
res.cookie('CloudFront-Policy', cfB64(Buffer.from(policy)), cookieOpts);
res.cookie('CloudFront-Signature', cfB64(signature), cookieOpts);
res.cookie('CloudFront-Key-Pair-Id', process.env.CF_KEY_PAIR_ID, cookieOpts);
res.json({ manifest: `https://media.example.com/hls/${sessionId}/master.m3u8` });
});
On the player side, the fetch must opt into credentials or the cookies are never sent:
// hls.js must be told to send cookies; the default XHR/fetch mode omits them cross-origin.
const hls = new Hls({
xhrSetup: (xhr) => { xhr.withCredentials = true; }, // applies to manifest AND every segment
});
hls.loadSource(manifestUrl);
hls.attachMedia(videoEl);
Warning: do not add Cookie to the CloudFront cache policy’s forwarded headers. CloudFront evaluates signed cookies before the cache lookup and excludes them from the key by default; adding them to the cache policy fragments the cache per session and reproduces the 4.1 % hit-ratio row from the benchmark table.
The sequence below shows why one cookie issuance beats 450 signed segment URLs.
Step 5 — Rotate signing keys with an overlap window
The rule that makes rotation safe: the overlap window must be at least the longest signed lifetime you ever issue, plus the longest cached-response TTL, plus a margin. Retire a key earlier than that and you 403 URLs that were legitimately issued minutes before the cutover.
#!/usr/bin/env bash
# rotate-cloudfront-key.sh — add a new public key to the trusted key group, then
# retire the old one only after the overlap window has fully elapsed.
set -euo pipefail
NEW_KID=$(aws cloudfront create-public-key \
--public-key-config "CallerReference=media-$(date +%s),Name=media-signer-$(date +%Y%m),EncodedKey=$(cat new-public.pem),Comment=rotation" \
--query 'PublicKey.Id' --output text)
# A key group holds up to 5 public keys — that headroom IS the rotation mechanism.
ETAG=$(aws cloudfront get-key-group --id "$KEY_GROUP_ID" --query 'ETag' --output text)
aws cloudfront update-key-group --id "$KEY_GROUP_ID" --if-match "$ETAG" \
--key-group-config "Name=media-signers,Items=[$NEW_KID,$OLD_KID]" # BOTH keys live simultaneously
echo "New key $NEW_KID accepted at the edge. Switch the signer to it now."
echo "Do NOT remove $OLD_KID for at least:"
echo " max signed TTL (1800s) + max object TTL (2592000s) + margin (86400s) = 30.7 days"
Symmetric HMAC rotation follows the same shape but rides in the token itself. The Worker in Step 2 already reads kid from the query string and looks the secret up in a map — publish k2, keep verifying k1, flip the signer to k2, wait out the window, then delete k1. The timeline below shows three keys with staggered sign and verify windows.
Step 6 — Bound clock skew explicitly
Signature expiry is a comparison between two clocks that you do not jointly control. The signer’s clock sets Expires; the edge’s clock evaluates it. A signer running 45 seconds fast issues URLs that are still valid; a signer running 45 seconds slow issues 15-minute URLs that the edge treats as 14-minute URLs. The asymmetric case is worse: a DateGreaterThan start time set from a fast clock produces URLs that are not yet valid when the browser fetches them, which surfaces as a random-looking 403 on roughly the first second of every session.
# 1. Prove the signer's clock is disciplined. Offset should be well under 100 ms.
chronyc tracking | grep -E 'System time|Last offset|Leap status'
# System time : 0.000041219 seconds fast of NTP time
# Last offset : +0.000003118 seconds
# Leap status : Normal
# 2. Compare the signer's clock against the edge's own Date header, which is the
# clock that actually adjudicates your expiry.
EDGE_DATE=$(curl -sI https://media.example.com/healthz | awk -F': ' '/^[Dd]ate:/ {print $2}' | tr -d '\r')
echo "edge : $(date -u -d "$EDGE_DATE" +%s)"
echo "local: $(date -u +%s)"
# 3. Assert the drift in CI so a drifting host fails the build, not production.
DRIFT=$(( $(date -u +%s) - $(date -u -d "$EDGE_DATE" +%s) ))
[ "${DRIFT#-}" -le 5 ] || { echo "FAIL: signer/edge clock drift ${DRIFT}s"; exit 1; }
Design rules that follow from this: never issue a TTL shorter than 60 seconds (skew becomes a meaningful fraction of the lifetime); always backdate DateGreaterThan by at least 60 seconds as in Step 1; and accept a small forward tolerance on expiry at the verifier, as the SKEW constant in Step 2 does. If your minimum viable TTL is under a minute, the correct fix is a signed cookie or a bearer header, not a tighter clock.
Parameter Reference
| Parameter | Platform / carrier | Meaning and failure mode |
|---|---|---|
Expires |
CloudFront canned policy | Epoch-UTC seconds. Present only in canned policies; mutually exclusive with Policy. |
Policy |
CloudFront custom policy | Base64 of the statement JSON using the +→- =→_ /→~ alphabet. Standard base64 verifies locally and 403s at the edge. |
Signature |
CloudFront | RSA-SHA1 over the exact policy bytes. Any whitespace change to the JSON invalidates it. |
Key-Pair-Id |
CloudFront | Public key ID inside the trusted key group. A key removed from the group 403s instantly and globally. |
Resource |
CloudFront policy | Full URL, one trailing * allowed. Omitting the scheme or using a CNAME the distribution does not serve fails silently as 403. |
AWS:SourceIp |
CloudFront policy | CIDR. Breaks under CGNAT, mobile network handoff, and corporate egress pools — use /24 at most, or omit. |
TrustedKeyGroups |
CloudFront cache behavior | Enabling it makes every request under that path pattern require a signature, including OPTIONS preflights. |
exp |
Cloudflare token / Images | Epoch-UTC seconds inside the signed message. If it is not in the canonical string, it is attacker-editable. |
sig |
Cloudflare token | Lowercase hex or base64url HMAC-SHA256. Must be compared in constant time. |
kid |
any HMAC scheme | Selects the verification secret. Unknown kid must fail closed, never fall back to a default key. |
max_age |
is_timed_hmac_validate_v0 |
Seconds of validity measured from the timestamp embedded in the token, not from issuance. |
http.referer |
Cloudflare ruleset | Empty on no-referrer policies. A rule that blocks empty referers breaks legitimate playback. |
SameSite / Secure |
signed cookies | None + Secure mandatory for cross-site media; without both, Chrome drops the cookie and every segment 403s. |
Domain |
signed cookies | Must be the registrable parent (.example.com) when page and media hosts differ. |
cacheTtl / cacheEverything |
Worker fetch options |
Governs the object stored under your custom cache key; unrelated to the signature lifetime. |
Tradeoffs and Edge Cases
Tradeoff: signature lifetime versus cache efficiency is a real dial, not a false one. Short TTLs limit the blast radius of a leaked URL but push you toward per-request signatures. The resolution is to decouple the two: keep the signature out of the cache key entirely (CloudFront does this for you, Cloudflare requires the Worker in Step 2) and then TTL becomes free. If you cannot control the cache key — a managed image pipeline, a third-party player that appends parameters — bucket the expiry to a coarse boundary so all users in the same hour share one signature and one cached object.
Tradeoff: IP pinning trades support tickets for security. AWS:SourceIp genuinely stops URL sharing, and it genuinely breaks playback for users on mobile networks that rotate egress IPs mid-session, for dual-stack clients that flip between IPv4 and IPv6, and for anyone behind a corporate proxy pool. On a consumer video product, expect a 0.5–2 % session-failure rate from IP pinning alone. Pin to a /24 (or /48 for IPv6) if you must pin, and never pin manifests and segments to different resolved addresses.
Warning: Referer is a filter, never an authorization. A referer rule blocks a scraper that embeds your <img> tag on their page. It does nothing against curl -H 'Referer: https://example.com/', against a headless browser, or against anyone who simply downloads the file once and rehosts it. Treat it as bandwidth protection layered in front of a signature — the two together are covered in hotlink protection with referer and token auth.
Edge case: signed URLs and format negotiation fight each other. If your edge varies the response on Accept to serve AVIF or WebP — the pattern described in AWS CloudFront cache behaviors for media and Cloudflare Image Resizing and Polish — your cache key contains a normalized Accept value and must exclude the signature. Get the ordering wrong in a Worker (deleting auth params after computing the key, or forgetting to normalize Accept before it) and you will serve AVIF bytes to a Safari 14 client that requested JPEG, from a cache entry that a different user’s signature created.
Edge case: range requests and 206 responses re-verify per range. A <video> element issues dozens of Range requests per file. Each one carries the signature and each one is verified independently, which is fine — but if your signature covers the full URL including a query string that the player mutates (some players append ?_=<cachebuster>), every range request fails. Sign the path only, never the full query string, and strip unknown parameters before verification.
Tradeoff: asymmetric signing costs CPU but removes a class of incident. With CloudFront’s RSA model, no edge configuration and no leaked CDN API token can mint a valid URL — only the private key can. With shared-secret HMAC, the verification secret and the signing secret are the same bytes, so anyone with read access to your Worker secrets can forge access to the entire library indefinitely. If your threat model includes CDN-account compromise, pay the 1.1 ms.
Debugging and Validation
Start by proving which layer rejected the request. A referer rule, an expired signature, and a missing origin object all surface as different status codes and different headers.
# 1. Full header dump: which layer answered, and did the edge even reach cache?
curl -sI "https://media.example.com/gallery/2026-07/thumb-0.avif?${POLICY_QS}" \
| grep -iE 'HTTP/|x-cache|age|x-amz-cf-pop|cf-cache-status|content-type|via'
# HTTP/2 200
# x-cache: Hit from cloudfront <- verification passed AND the key was signature-free
# age: 41822
# x-amz-cf-pop: LHR50-C1
# 2. Decode a CloudFront policy to read the expiry your signer actually emitted.
# Reverse the non-standard alphabet before base64 -d.
echo "$POLICY_B64" | tr -- '-_~' '+=/' | base64 -d | python3 -m json.tool
# {"Statement":[{"Resource":"https://media.example.com/gallery/2026-07/*",
# "Condition":{"DateLessThan":{"AWS:EpochTime":1785000000}}}]}
date -u -d @1785000000 # human-readable expiry, in UTC — compare against the edge Date header
# 3. Verify an RSA signature locally without deploying anything.
echo -n "$POLICY_JSON" > /tmp/policy.json
echo "$SIG_B64" | tr -- '-_~' '+=/' | base64 -d > /tmp/policy.sig
openssl dgst -sha1 -verify public.pem -signature /tmp/policy.sig /tmp/policy.json
# Verified OK <- signer and key group agree; a 403 now means expiry, IP, or resource mismatch
# 4. Recompute an HMAC token the same way the Worker does, to isolate canonical-string bugs.
printf '%s\n%s' '/media/hero.mp4' '1785000000' \
| openssl dgst -sha256 -hmac "$SIGNING_KEY_K1" -hex | awk '{print $2}'
# 5. Prove the signature is NOT in the cache key: two different signatures, one cached object.
for i in 1 2; do
curl -sI "https://media.example.com/media/hero.mp4?exp=${EXP}&sig=${SIGS[$i]}&kid=k1" \
| grep -iE 'cf-cache-status|age'
done
# cf-cache-status: HIT / age: 9021 <- both requests, identical age => same object. Correct.
# Two MISSes with age: 0 means the signature IS in the key — fix the cache key, not the TTL.
For hit-ratio regressions specifically, watch CacheHitRate (CloudFront) or the cache analytics HIT/MISS split (Cloudflare) rather than curl, since a single warm PoP will happily lie to you. A sudden drop that coincides with a deploy almost always means a new query parameter entered the cache key; the systematic walk-through for that is debugging CloudFront cache misses for images, and wiring an alert so it never goes unnoticed for a week is covered under monitoring and regression for media delivery.
Finally, treat 403 rate as a first-class SLI. Plot it split by cause — expired, bad signature, unknown key ID, referer block — because each has a different owner. A spike in “unknown key ID” is a rotation that removed a key too early. A spike in “expired” that follows the working day is clock drift on one signer host. A steady baseline of “referer block” is usually a partner domain that changed its hostname and needs an allowlist update.
Related
- Generating CloudFront Signed URLs for Private Media — key groups, origin access control, and canned versus custom policies end to end
- Hotlink Protection with Referer and Token Auth — layering a referer allowlist in front of HMAC token verification without breaking empty-referer clients
- Short-Lived Signed URLs and CDN Cache Hit Rate — measuring and repairing the hit-ratio damage of per-user signatures
- AWS CloudFront Cache Behaviors for Media — cache policies and cache-key design that signature parameters must not disturb
- Cloudflare Image Resizing and Polish — the transformation pipeline that signed image URLs sit in front of
- Cache-Control Headers for Image and Video Assets — the origin-side TTLs that set the floor on your key-rotation overlap window
- CDN & Edge Media Delivery — the parent section covering edge transformation, negotiation, caching, and monitoring