When a web tool lets people "download" a file that lives on someone else's CDN, the lazy version is one line: fetch(req.query.url) and stream the result back. It works on day one. On day two it is an open proxy, and anyone can bounce any URL through your domain.
On SnapViewer the media files sit on a third-party CDN, and the browser cannot save them with a clean file name because of CORS and missing Content-Disposition headers. So we need a small server-side download route. This post shows how we built it on Cloudflare Pages Functions so that it only serves files the app itself handed out, for one hour, from one host. The whole thing is about 40 lines and uses only WebCrypto, with no dependencies.
The rules
- Only URLs that our own
/api/resolveendpoint returned in the last hour are accepted. - Only one host family is ever fetched (an allowlist, not a blocklist).
- Range requests pass through, so video seeking and resumed downloads work.
- The file reaches the user unchanged: same bytes, same size, no re-encoding.
- A takedown must also kill links that were already handed out.
Signing: 22 characters per file
A profile page can list hundreds of files, so a full JWT per link would bloat the response. Instead, each link carries a short HMAC over url|account|expiry:
const enc = new TextEncoder(); const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); async function key(secret) { return crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); } export async function signUrl(u, a, e, secret) { const sig = await crypto.subtle.sign('HMAC', await key(secret), enc.encode(`${u}|${a}|${e}`)); return b64u(sig).slice(0, 22); // 132 bits is plenty for a 1-hour link } The link becomes /api/dl?u=<file>&a=<account>&e=<expiry>&n=<name>&s=<sig>.
Two details matter here:
- The expiry is inside the signature. Changing
eto next year breaks the HMAC. - The account name is signed too. That is what makes rule 5 possible (more below).
The file name n is deliberately not signed. It is only cosmetic, so the endpoint sanitizes it instead.
Verification compares in constant time, so the check doesn't leak how many characters matched:
export async function verifyUrl(u, a, e, s, secret) { if (!u || !s || !e || Number(e) < Date.now() / 1000) return false; const want = await signUrl(u, a || '', e, secret); if (want.length !== String(s).length) return false; let diff = 0; for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ String(s).charCodeAt(i); return diff === 0; } The endpoint
const ALLOWED = /(^|\.)sc-cdn\.net$/i; export async function onRequestGet({ request, env }) { const q = new URL(request.url).searchParams; const u = q.get('u') || '', a = q.get('a') || '', e = q.get('e') || '', s = q.get('s') || ''; if (!(await verifyUrl(u, a, e, s, env.DL_SECRET).catch(() => false))) return new Response('This download link expired.', { status: 410 }); if (isBlockedUser(a, env)) return new Response('Removed at the owner\'s request.', { status: 451 }); let url; try { url = new URL(u); } catch { return new Response('bad', { status: 400 }); } if (url.protocol !== 'https:' || !ALLOWED.test(url.hostname)) return new Response('forbidden', { status: 403 }); const headers = {}; const range = request.headers.get('range'); if (range) headers.Range = range; const up = await fetch(url.href, { headers }); if (!up.ok && up.status !== 206) return new Response('Upstream error', { status: 502 }); const out = new Headers(); for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { const v = up.headers.get(h); if (v) out.set(h, v); } // ...file name + content-disposition, see below out.set('cache-control', 'no-store'); out.set('x-robots-tag', 'noindex'); return new Response(up.body, { status: up.status, headers: out }); } Even with a valid signature, the host check still runs. If the secret ever leaked, the worst case would be "fetch something from that one CDN", not "fetch anything on the internet".
up.body is streamed straight through, so the Worker never buffers a 30 MB video in memory, and the user gets the exact bytes the CDN stores.
Status codes that explain themselves
Each failure has its own code and a plain-text sentence, because these links get pasted into chats and opened hours later:
| Code | Meaning |
|---|---|
| 410 | The link expired: look the profile up again |
| 451 | The owner asked for removal |
| 403 | The host is not allowed (someone is probing) |
| 502 | The CDN said no: the file probably expired upstream |
A generic 500 for all of these would turn every support email into guesswork.
File names that survive phones
The CDN sometimes answers with binary/octet-stream. iOS and Android then file the download under "Other" or refuse to preview it. So we fix the type from the extension and send both forms of the file name:
const TYPES = { mp4: 'video/mp4', jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }; let name = String(q.get('n') || 'file.mp4').replace(/["\\\r\n/]/g, '').slice(0, 120) || 'file.mp4'; const ext = (name.match(/\.(mp4|jpg|png|webp)$/i) || [])[1]; if (!ext) name += '.mp4'; if (!/^(video|image)\//.test(out.get('content-type') || '')) out.set('content-type', TYPES[(ext || 'mp4').toLowerCase()]); out.set('content-disposition', `attachment; filename="${name.replace(/[^\x20-\x7e]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`); The plain filename= is ASCII-only for old clients. filename*= carries the real UTF-8 name, such as accents or Arabic, for everything modern. Quotes, backslashes and newlines are stripped first, because they are the characters used for header injection.
Takedowns that reach old links
This is why the account name is part of the signature. The opt-out list is checked inside /api/dl as well as when links are created. So when someone asks to be removed, every link we already gave out for that account answers 451 on its next use, without waiting an hour for expiry. Because a is signed, nobody can swap it for another name to dodge the list.
Counting requests without burning the free tier
Pages Functions on the free plan allow 100k requests a day. A middleware scoped to /api/* only, so static pages stay free, counts calls in D1. It writes only 1 in 10 hits (adding 10 each time), because one D1 write per request was eating about 40% of the daily rows_written budget:
if (env.STATS && Math.random() < 1 / 10) { ctx.waitUntil(env.STATS.prepare( 'INSERT INTO daily (day, site, kind, n) VALUES (?, ?, ?, ?) ' + 'ON CONFLICT(day, site, kind) DO UPDATE SET n = n + excluded.n' ).bind(day, host, kind, 10).run().catch(() => {})); } Takeaways
- Any endpoint that fetches a URL from a query string needs a signature and a host allowlist. One of the two is not enough.
- Put everything you might want to revoke later (the expiry, the account) inside the signature.
- Stream, don't buffer, and pass
Rangethrough. - Give each failure its own status code and a sentence a human can read.
This article was drafted with AI assistance from our production code and reviewed before publishing.

