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.

Signed URL verification path at the CDN edge An auth service holding a private key from a key store issues a signed URL to the browser. The browser requests it from the CDN edge, which runs three checks in order: MAC or signature match, expiry against the edge clock, and policy conditions such as path prefix and source IP. Any failure returns 403. On success the edge computes a cache key with the signature parameters removed and serves from cache or fetches from a private origin. Auth service signs the canonical string Key store (KMS / secret) private key + active kid Browser / player GET /v/hero.mp4?sig=… CDN edge PoP verifier runs before cache lookup 1 · proof matches canonical string 2 · expiry > edge clock ± skew 3 · policy: path prefix, source IP Trusted key group kid → public key / HMAC secret 403 Forbidden never cached, never logged as hit any check fails cache key = URL minus sig params one cached object for all viewers Private origin (S3 / R2) reachable only via signed edge identity on miss

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.

Edge cache hit ratio by signing scheme Horizontal bars showing edge cache hit ratio: unsigned baseline 99.2 percent, signature excluded from cache key 99.1 percent, signed cookie 99.0 percent, hourly bucketed shared expiry 97.6 percent, and per-user 15 minute signature inside the cache key 4.1 percent. Edge cache hit ratio by signing scheme (10M req/day, 180 KB mean object) Unsigned URL (baseline) 99.2 Signature outside cache key 99.1 Signed cookie per session 99.0 Hourly bucketed shared expiry 97.6 Per-user 15 min sig in key 4.1 — origin egress 1.73 TB/day 0 25 50 75 100 edge hit ratio (%) Same asset set, same TTL; only the signing scheme differs between rows.

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.

Signed cookie session for an HLS ladder The player posts to the auth service and receives three CloudFront cookies. It then requests the master manifest from the CDN edge with those cookies; the edge verifies once and fetches the manifest from origin on a miss. All 450 subsequent segment requests reuse the same cookies and are served as edge hits with no further origin traffic. Player Auth service CDN edge Origin POST /api/playback-session (bearer JWT) Set-Cookie: CloudFront-Policy + -Signature + -Key-Pair-Id (path-scoped) GET /hls/<sid>/master.m3u8 (withCredentials) verify once → miss manifest bytes 200 master.m3u8 (cookie NOT in cache key) GET seg-0001.m4s … seg-0450.m4s — same three cookies 450 responses · 450 edge hits · 0 extra origin fetches One RSA signature covers the whole session; per-segment signing would cost 450 signatures and 450 distinct 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.

Overlapping key rotation windows Three keys each have a wide dashed verification window and a narrower solid signing window. Key a1 signs days 0 to 7 and verifies through day 14; key b2 signs days 7 to 14 and verifies through day 21; key c3 signs days 14 to 21 and verifies through day 28. Each key stays verifiable for seven days after it stops signing. Key rotation: signing window vs verification window key a1 signs verify only key b2 signs verify only key c3 signs verify only cutover 1 cutover 2 day 0 day 7 day 14 day 21 day 28 Overlap must exceed max signed TTL + max cached object TTL, or in-flight URLs 403 at cutover. Removing a key is the only irreversible step — do it last, and only after the window has fully elapsed.

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.