Preventing Hotlinking of Uploaded Media

At the edge, allow media requests whose Referer or Sec-Fetch-Site shows they came from your own pages, allow requests with no referrer only for direct navigations, and reject or downgrade cross-site embeds; for anything that must not be copied around at all, replace the static URL with an HMAC token in the path that expires after a few hours; and set Cross-Origin-Resource-Policy: same-site so modern browsers refuse cross-site embeds even when a check is bypassed.

Hotlinking is another site putting <img src="https://media.yourapp.com/…"> or <video src> on its pages, so your CDN serves their traffic. For user-uploaded media it usually starts when one image goes viral on a forum; the first sign is an egress bill several times the usual and a hit rate your own traffic does not explain. This page is part of secure media delivery in media processing and delivery pipelines. For truly private media, use signed access instead — CloudFront signed URLs vs signed cookies for media — because hotlink protection only controls where public media is shown, not who sees it.

When to use this approach

  • Media is meant to be public on your site (profile photos, listing images, public posts) but not to be embedded by others.
  • You pay per gigabyte of CDN egress, or you run an image-transformation service billed per request, as in resizing images on the fly with a Cloudflare Worker.
  • You can run code at the edge: a Cloudflare Worker, a CloudFront Function, or a Fastly VCL/Compute service.

Prerequisites

  1. A dedicated media hostname (media.example.com), so rules apply only to media.
  2. An edge runtime — the code below is a Cloudflare Worker; the same logic ports directly to a CloudFront Function.
  3. A list of your own origins that may embed media, including staging and native app webviews if any.
  4. For tokens: an HMAC secret stored as an edge secret (wrangler secret put HOTLINK_KEY).

What the browser tells you about an embed

Two headers identify where a request came from. Referer carries the embedding page’s origin (or full URL, depending on its referrer policy); privacy settings and some extensions strip it. Sec-Fetch-Site is set by the browser itself and cannot be modified by page scripts: same-origin, same-site, cross-site or none (a direct navigation, typed or bookmarked). Sec-Fetch-Dest says what the request is for — image, video, document.

Classifying media requests by fetch metadata and referrer Requests with Sec-Fetch-Site same-origin or same-site are allowed. Requests with Sec-Fetch-Site none are direct navigations and are allowed. Cross-site requests are allowed only if the Referer is on the allow-list, such as a partner domain; otherwise they get a 403 or a placeholder image. Requests with no fetch metadata fall back to Referer checks. Sec-Fetch-Site first, Referer second Sec-Fetch-Site? same-origin/site allow none direct visit: allow cross-site check Referer list header missing old client: Referer partner on allow-list → allow image → placeholder, 200 video → 403 Never block on a missing Referer alone — privacy tools strip it from your own pages too.
Fetch metadata is set by the browser and unforgeable by page scripts; the Referer is the fallback for clients that do not send it.

Implementation

// worker.ts — in front of the media bucket
export interface Env {
  MEDIA: R2Bucket;
  HOTLINK_KEY: string;           // HMAC secret for tokenised paths
}

const OWN_SITES = new Set(["example.com"]);                        // registrable domains
const PARTNERS = new Set(["https://partner.example.org"]);          // exact origins allowed to embed
const PLACEHOLDER_KEY = "static/hotlink-placeholder.png";

function registrable(host: string): string {
  const parts = host.split(".");
  return parts.slice(-2).join(".");     // good enough for .com/.org; use a PSL library for co.uk etc.
}

type Verdict = "allow" | "placeholder" | "deny";

export function judge(req: Request): Verdict {
  const site = req.headers.get("Sec-Fetch-Site");
  const dest = req.headers.get("Sec-Fetch-Dest") ?? "";
  const referer = req.headers.get("Referer");

  if (site === "same-origin" || site === "same-site" || site === "none") return "allow";

  let refOrigin: string | null = null;
  try { refOrigin = referer ? new URL(referer).origin : null; } catch { refOrigin = null; }

  if (refOrigin && PARTNERS.has(refOrigin)) return "allow";
  if (refOrigin && OWN_SITES.has(registrable(new URL(refOrigin).hostname))) return "allow";

  if (site === "cross-site") return dest === "image" ? "placeholder" : "deny";

  // No fetch metadata (old browsers, bots, curl): decide on Referer alone, and
  // allow when it is missing, because many legitimate clients strip it.
  if (!refOrigin) return "allow";
  return dest === "image" || /\.(jpe?g|png|webp|avif|gif)$/i.test(new URL(req.url).pathname)
    ? "placeholder" : "deny";
}

async function hmac(key: string, data: string): Promise<string> {
  const k = await crypto.subtle.importKey("raw", new TextEncoder().encode(key),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const sig = await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(data));
  return btoa(String.fromCharCode(...new Uint8Array(sig).slice(0, 16)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    let key = url.pathname.slice(1);

    // Tokenised paths: /t/<expiry>/<sig>/<key> — for media that should not be reusable at all.
    const t = key.match(/^t\/(\d{10})\/([A-Za-z0-9_-]{22})\/(.+)$/);
    if (t) {
      const [, exp, sig, rest] = t;
      if (Number(exp) < Date.now() / 1000) return new Response("Link expired", { status: 410 });
      if (sig !== (await hmac(env.HOTLINK_KEY, `${exp}/${rest}`))) return new Response("Forbidden", { status: 403 });
      key = rest;
    } else {
      const verdict = judge(req);
      if (verdict === "deny") return new Response("Forbidden", { status: 403 });
      if (verdict === "placeholder") key = PLACEHOLDER_KEY;
    }

    const obj = await env.MEDIA.get(key);
    if (!obj) return new Response("Not Found", { status: 404 });
    return new Response(obj.body, {
      headers: {
        "Content-Type": obj.httpMetadata?.contentType ?? "application/octet-stream",
        "Cache-Control": key === PLACEHOLDER_KEY ? "public, max-age=3600" : "public, max-age=86400",
        // The verdict depends on these headers, so shared caches must key on them.
        "Vary": "Sec-Fetch-Site, Referer",
        "Cross-Origin-Resource-Policy": "same-site",
        "ETag": obj.httpEtag,
      },
    });
  },
} satisfies ExportedHandler<Env>;

Line-by-line on the decisions that matter

  • Sec-Fetch-Site before Referer. It is present in all current Chromium, Firefox and Safari versions, cannot be set by page JavaScript, and is not affected by Referrer-Policy: no-referrer on the embedding page — the most common way hotlinkers hide the Referer.
  • none is allowed. That is someone opening the image URL directly: a right-click “open image in new tab”, a bookmark, a link pasted into a chat app that fetches a preview. Blocking it breaks legitimate sharing.
  • Missing Referer without fetch metadata is allowed. Old clients and privacy extensions strip Referer from your own pages too. Denying on absence punishes your users to slightly inconvenience hotlinkers.
  • Placeholder for images, 403 for video. A small “view this on example.com” image turns a hotlink into free advertising and costs a few kilobytes. For video there is no graceful equivalent; refuse it.
  • Vary: Sec-Fetch-Site, Referer. Without it, a downstream shared cache could store the placeholder in response to a hotlink and serve it to your own pages. The Worker’s own fetch from R2 is uncached here; if you add edge caching, include the verdict in the cache key.
  • Cross-Origin-Resource-Policy: same-site. Browsers enforcing CORP refuse to render the response in a cross-site page’s <img> or <video>, even if the edge check was bypassed. It is a second, browser-side lock.

How bandwidth changes after enabling it

Daily media egress before and after hotlink protection Daily egress hovers around 1.2 terabytes for a week, spikes to 5.8 terabytes when a user image goes viral on a forum, and after protection is enabled falls to 1.3 terabytes, with placeholder responses accounting for under 1 percent. Daily media egress (TB), one image goes viral elsewhere 6 3 0 protection on 5.8 TB — forum embed back to ~1.3 TB Placeholder images served to the forum were 0.6% of the spike's bytes — and they carried your URL.
Hotlink traffic is bursty and third-party driven; protection turns an unbounded cost back into your own traffic plus a rounding error.

Configuration gotchas

Your own emails show broken images. Webmail clients proxy images (Gmail’s image proxy fetches from Google’s servers with no Referer and no fetch metadata) — allowed by the rules above. But some desktop clients send a Referer of their own web origin; add those origins to the allow-list or serve email images from a separate path without checks.

Native apps’ webviews are blocked. An in-app webview loading file:// or a custom scheme sends Sec-Fetch-Site: cross-site with no useful Referer. Give apps their own media path with a token, or allow a custom header your app sets.

Social previews disappear. Link-preview crawlers from chat and social apps fetch Open Graph images server-side, usually without a Referer: allowed. If previews still break, check that you did not add CORP same-origin to the Open Graph image path; use a dedicated /og/ path with no CORP header.

CloudFront Functions cannot read the body or call R2. Port only judge() to a viewer-request function and rewrite the URI to the placeholder key; the origin fetch stays with CloudFront.

Rolling it out without breaking your own site

Hotlink rules are easy to get subtly wrong for your own traffic, so deploy them in report-only mode first. Compute the verdict for every request, but always serve the real object, and log the verdict with the Referer origin, Sec-Fetch-Site and Sec-Fetch-Dest. After a few days, group the would-be denials by referring origin: the list usually contains the hotlinkers you expected, plus a handful of legitimate sources you forgot — a staging domain, a marketing site on a different registrable domain, an email provider’s web client, a partner’s embed.

Add those to the allow-list, then switch images to placeholder mode while video stays in report-only, and finally enforce for video. Keep the verdict logging permanently; it doubles as a live list of who embeds your media, which is useful commercial information as well as a security signal.

Tokens for media that must not travel

Anatomy of a tokenised media path A tokenised path has four parts: the t prefix, a ten-digit expiry timestamp, a 22-character HMAC signature, and the object key. The signature covers the expiry and the key, so changing either invalidates it. /t/<expiry>/<sig>/<key> /t/ 1789032000 k3Jq0vR8x_2mTzLp9WcA1g photos/9c1f/800.webp expiry (unix s) HMAC-SHA256, 16 bytes object key signed string = "1789032000/photos/9c1f/800.webp" — change the key or the expiry and the check fails. Round expiries to the hour so every viewer in that hour gets the same URL — the CDN can still cache it. Relative URLs inside a manifest inherit the /t/…/ prefix, so tokens also work for HLS packages.
Rounding the expiry keeps tokenised URLs cacheable while bounding how long a copied link keeps working.

Verification

M=https://media.example.com/photos/9c1f/800.webp

# Own page: allowed, real image.
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H 'Sec-Fetch-Site: same-site' -H 'Sec-Fetch-Dest: image' "$M"
# 200 48231

# Foreign page embedding an image: placeholder.
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -H 'Sec-Fetch-Site: cross-site' \
  -H 'Sec-Fetch-Dest: image' -H 'Referer: https://forum.example.net/thread/1' "$M"
# 200 3120

# Foreign page embedding a video: refused.
curl -s -o /dev/null -w '%{http_code}\n' -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Dest: video' \
  https://media.example.com/videos/9c1f/720.mp4
# 403

# Direct visit: allowed.
curl -s -o /dev/null -w '%{http_code}\n' -H 'Sec-Fetch-Site: none' "$M"
# 200

Frequently Asked Questions

Can’t hotlinkers just fake the Referer?

Server-side scrapers can send any header, but hotlinking works through visitors’ browsers, and browsers set Referer and Sec-Fetch-* themselves. A page cannot make its visitors’ browsers claim to be on your site. Scrapers downloading and re-hosting your media are a different problem, handled by rate limits and tokens.

Does this affect SEO for images?

Search engine image crawlers fetch without a Referer and appear as direct requests, so they are allowed. Image search results that embed your image as a thumbnail on the search page are cross-site embeds; add the search engine’s origin to the partner list if you want those thumbnails to show.

Should I use tokens for everything instead?

Tokens make every URL expire, which breaks bookmarks, shared links and long-lived caches. Use them for media where that is the point — paid downloads, pre-release content — and fetch-metadata checks for ordinary public media.