You have a Worker, a database, object storage, an auth layer, and Stripe. Now you want to sell a video course. The naive instinct — "I'll just put the .mp4 files in R2 and <video src> them" — is half right and half a trap. This guide walks the three architectures that actually work on Cloudflare, when each one wins, and the exact code paths to implement them. We'll finish on the one simpleappshipper.com actually runs in production for 242 tutorial videos.
Why video is its own problem
A 200 MB PDF and a 200 MB MP4 are not the same workload, even though they're the same number of bytes. Three things make video hard:
- Watch time, not file size, drives cost. Storage is cheap. Delivery is where the bill lives — every minute a student watches is bytes moving out of your origin. A 60 GB course library that 5,000 students each watch 80 minutes of per month is 400,000 minutes of delivered video, not 60 GB.
- Bitrate ladders make playback smooth. A single 1080p MP4 stutters on a phone in a tunnel. Real video products encode each source into multiple resolutions (240p / 360p / 720p / 1080p) and let the player switch based on bandwidth. That's called adaptive bitrate (ABR), and it's normally HLS or DASH — a manifest file plus thousands of 4–10 second segments.
- Piracy is one click away. A signed URL to your .mp4 ends up in a Telegram channel within hours if it lasts long enough. Real protection means short-lived tokens, origin restrictions, and ideally a player that doesn't expose the raw file URL at all.
Cloudflare has products for every layer of this — Stream, R2, Workers, Durable Objects, Access, Turnstile — but they're not interchangeable. Picking the right combination is most of the work. Let's look at the three serious options.
Architecture A — Stream-first (managed playback)
This is the option Cloudflare itself recommends for paid video products, and it's the one that buys away the most infrastructure pain. Cloudflare Stream handles ingest, encoding into an ABR ladder, hosting, signed playback tokens, an embeddable player, allowed-origin rules, and analytics. You bring billing, entitlement, and a frontend.
Pricing is legible: $5 per 1,000 stored minutes and $1 per 1,000 delivered minutes, same model for VOD and live. Pro and Business sites get a small Stream allowance bundled in (100 stored, 10,000 delivered).
Why this wins for most teams: every part of "make video play smoothly on mobile" — encoder, ABR packaging, manifest cache, segment auth, fallback bitrates, dropped-frame analytics — is somebody else's problem. You write a Worker that decides can this user watch this video? and Stream does the rest.
Why you might not pick it: delivery cost scales linearly with watch time, with no plateau. A long-tail back-catalog that gets watched a lot can cost more on Stream than on a self-managed pipeline you already amortise.
Architecture B — Hybrid: R2 archive + Stream playback
This is the "I already use R2 and don't want to throw that away" option. Creators upload masters to R2, a Worker triggers Stream's upload-by-link to ingest the master, Stream produces the ABR ladder, and students play from Stream.
You get the best of both: R2 is your source of truth (durable, cheap, your own copy of every master), and Stream is your viewer-facing surface (encoding + playback). If you ever want to migrate off Stream — to a different vendor, to your own packaging pipeline, anywhere — the masters are already in your bucket, untouched.
This is the architecture I recommend if you have any anxiety about vendor lock-in around your raw video files. Cost is essentially Stream-first + a small R2 storage line.
Architecture C — R2-centric self-managed delivery
This is what simpleappshipper.com actually runs. Your videos live as plain MP4/MOV files in R2, a Worker proxies every download, and the same Worker decides whether the request is allowed.
It's the cheapest line-item on paper because there's no Stream bill, and it's the most flexible because you own every byte of the pipeline. The trade-off is that you have to think about everything Stream would have handled: content-type quirks, HTTP Range, cache headers, content protection, and (if you want ABR) a transcoding pipeline.
There's an important Cloudflare-policy nuance here: on Free / Pro / Business zones, Cloudflare's terms reserve the right to redirect sites that serve large volumes of video traffic without the appropriate paid product. The recommended paid paths are Stream or Enterprise Stream Delivery. For low-to-moderate volume tutorial content this rarely bites in practice, but it's the reason teams with serious traffic move to Stream eventually.
We'll dive into the R2-centric path in detail at the end of the guide — it's what simpleappshipper.com ships, and it's the one you're most likely to actually write from scratch if you've already got Workers + R2.
Component cheat sheet
| Component | Best at | Watch out for |
|---|---|---|
| Cloudflare Stream | ABR encoding, signed tokens, embedded player, allowed-origins, analytics, creator uploads | Delivery $$ scales linearly with watch time |
| R2 | Source masters, archives, PDFs, thumbnails, downloadable assets | No ABR / no player / presigned URLs don't work on custom domains |
| Workers | Paywall logic, token issuance, webhook endpoints, the actual edge API | You own the auth/billing orchestration |
| Durable Objects | Authoritative entitlement state, idempotent Stripe webhooks, nonces | A bit more infra than KV — but consistency you can rely on |
| KV | Read-cache for entitlements and metadata | Eventually consistent — updates can take ~60s to fan out |
| Cloudflare Access | Protecting internal creator/admin tools | Per-seat pricing (~$7/user/mo) — wrong tool for thousands of paying viewers |
| Turnstile | Bot protection on signup, login, coupon, recovery flows | Server-side siteverify is mandatory |
The split that matters: keep your app session, your billing state, and your playback authorisation as three separate things.
- App session — medium-lived cookie or JWT proving "this is user X".
- Billing state — authoritative source of "user X has an active Pro subscription". Lives in a Durable Object or D1.
- Playback token — short-lived, path-scoped credential proving "user X may watch this specific video right now".
Mixing them turns a leaked session cookie into a permanent piracy key.
Cost scenarios
Three workload sizes, ignoring payment processor fees / taxes / external transcoding compute:
| Scenario | Users | Delivered min/mo | Stored min | |---|---:|---:|---:| | Small | 500 | 50,000 | 2,000 | | Medium | 5,000 | 500,000 | 10,000 | | Large | 50,000 | 5,000,000 | 50,000 |
| Architecture | Small | Medium | Large | What drives the bill | |---|---:|---:|---:|---| | A. Stream-first | ~$65/mo | ~$555/mo | ~$5,255/mo | Workers Paid + Stream storage + Stream delivery | | B. Hybrid R2 + Stream | ~$70/mo | ~$570/mo | ~$5,290/mo | Same as A + R2 archive bytes + minor ops | | C. R2-centric | ~$6–30/mo | ~$10–35/mo | ~$35–90/mo | Workers, R2 storage, R2 Class B reads |
The R2-centric row looks unreasonably cheap because it excludes the parts you now own: encoding compute, packaging, the player, manifest caching, content-protection ops, and any cost of moving off Cloudflare delivery if you hit the policy ceiling. The Stream column is "buy your way out of all of that."
Implementing the paywall — Worker code
The next three Worker snippets are the meat of any course backend. They're written against Stream (Architecture A), but the Stripe webhook and short-lived signed URL pieces apply equally well if you go R2-centric.
1. Issuing a Stream playback token
The Worker decides can this user watch; Stream decides is this playback request valid. That split keeps business logic at the edge and lets Stream enforce delivery.
// Cloudflare Worker — TypeScript
// Assumes:
// - STREAM binding is configured in wrangler.toml
// - The video in Stream is already marked requireSignedURLs: true
// - requireSession() and hasVideoEntitlement() exist (DO / D1 lookups)
interface Env {
STREAM: StreamBinding;
STREAM_CUSTOMER_CODE: string;
}
async function requireSession(request: Request): Promise<{ userId: string } | null> {
const cookie = request.headers.get("Cookie") || "";
const match = cookie.match(/app_session=([^;]+)/);
if (!match) return null;
// Replace with your real session verification (JWT, signed cookie, etc).
return { userId: decodeURIComponent(match[1]) };
}
async function hasVideoEntitlement(userId: string, videoUid: string): Promise<boolean> {
// Replace with a Durable Object or D1 lookup.
return Boolean(userId && videoUid);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== "/api/playback-token") {
return new Response("Not found", { status: 404 });
}
const session = await requireSession(request);
if (!session) return new Response("Unauthorized", { status: 401 });
const videoUid = url.searchParams.get("video");
if (!videoUid) return new Response("Missing video UID", { status: 400 });
if (!(await hasVideoEntitlement(session.userId, videoUid))) {
return new Response("Forbidden", { status: 403 });
}
// Simplest documented Worker path — uses the STREAM binding to mint a token.
const token = await env.STREAM.video(videoUid).generateToken();
const base = `https://customer-${env.STREAM_CUSTOMER_CODE}.cloudflarestream.com/${token}`;
return Response.json(
{
token,
iframeUrl: `${base}/iframe`,
hlsUrl: `${base}/manifest/video.m3u8`,
dashUrl: `${base}/manifest/video.mpd`,
},
{ headers: { "Cache-Control": "no-store" } },
);
},
} satisfies ExportedHandler<Env>;2. Stripe webhook → Durable Object entitlement
Tokens are useless if your "is the user paid?" answer is wrong. Stripe is the source of truth for billing; your Durable Object is the source of truth for what that means for access.
// Verifies Stripe's Stripe-Signature header using Web Crypto,
// then forwards the event into a per-customer Durable Object.
interface Env {
STRIPE_WEBHOOK_SECRET: string;
SUBSCRIPTIONS: DurableObjectNamespace;
}
async function hex(buffer: ArrayBuffer): Promise<string> {
return [...new Uint8Array(buffer)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function verifyStripeSignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
toleranceSeconds = 300,
): Promise<boolean> {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const [k, v] = p.split("=");
return [k, v];
}),
);
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const digest = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${timestamp}.${rawBody}`),
);
return (await hex(digest)) === provided;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (new URL(request.url).pathname !== "/webhooks/stripe") {
return new Response("Not found", { status: 404 });
}
const rawBody = await request.text();
const sig = request.headers.get("Stripe-Signature");
if (!(await verifyStripeSignature(rawBody, sig, env.STRIPE_WEBHOOK_SECRET))) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.deleted":
case "invoice.payment_failed": {
const customerId =
event.data?.object?.customer ||
event.data?.object?.customer_id ||
"unknown";
const id = env.SUBSCRIPTIONS.idFromName(String(customerId));
const stub = env.SUBSCRIPTIONS.get(id);
await stub.fetch("https://internal/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventId: event.id,
eventType: event.type,
payload: event.data?.object,
}),
});
}
}
return new Response("ok", { status: 200 });
},
};Two Stripe details that matter here, both bite first-timers:
- Use the raw request body.
request.text()works;request.json()does not, because parsing reformats the bytes and the signature no longer matches. - Webhooks are fulfilment, not Checkout success. The user's browser sees
success_urlimmediately. Stripe sendscheckout.session.completeda beat later. Always grant access on the webhook — never trust the redirect.
3. Validating a short-lived signed asset URL (R2 downloads)
For PDFs, subtitle files, or any custom asset you want to issue a one-shot link for — common in Architecture C — sign the path, the expiry, and a nonce. Verify on the way back in.
interface Env {
SIGNING_SECRET: string;
}
async function hmacHex(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(message),
);
return [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function validateSignedRequest(request: Request, env: Env): Promise<boolean> {
const url = new URL(request.url);
const exp = url.searchParams.get("exp");
const nonce = url.searchParams.get("nonce");
const sig = url.searchParams.get("sig");
if (!exp || !nonce || !sig) return false;
if (Date.now() > Number(exp)) return false;
const canonical = `${url.pathname}:${exp}:${nonce}`;
const expected = await hmacHex(env.SIGNING_SECRET, canonical);
// For high-risk flows, also store the nonce in a Durable Object
// and reject any second use.
return expected === sig;
}Three rules for any handwritten signed URL:
- Bind the signature to the path. A signature on just
(exp, nonce)is reusable across files. - Keep the TTL tight. Minutes, not days. The window is the leak window.
- Track nonces for one-time semantics — otherwise a single shared URL is a multi-viewer key.
What simpleappshipper.com actually does
Time to look at real code. SAS hosts 242 tutorial .mov files in R2 (~60 GB), of which most are Pro-gated. We picked Architecture C — R2 + a Worker proxy — because the cost math at our scale (small/medium) is dramatically better than Stream, and because the content is already encoded H.264+AAC and doesn't need an ABR ladder for the audience it serves.
Here's the actual handler from saas/src/index.js:3855:
if (path.startsWith('/api/video/') && (method === 'GET' || method === 'HEAD')) {
const key = decodeURIComponent(path.slice('/api/video/'.length));
if (!key.startsWith('tutorials/') || key.includes('..')) {
return new Response('Bad path', { status: 400 });
}
// 1. Is this video free or Pro-gated? Source of truth: video-manifest.js.
const keyIsFree = await isVideoFree(key, env);
// 2. If gated, look up the caller's subscription in D1 by device ID.
if (!keyIsFree) {
const did = url.searchParams.get('d') || request.headers.get('X-Device-ID');
let ok = false;
if (did) {
const u = await env.DB.prepare(
'SELECT subscription_tier, subscription_expires_at FROM users WHERE device_id=?'
).bind(did).first();
ok = !!(u && u.subscription_tier && u.subscription_tier !== 'free'
&& u.subscription_expires_at
&& new Date(u.subscription_expires_at) > new Date());
}
if (!ok) {
return new Response(
JSON.stringify({ error: 'Active subscription required', paywall: true }),
{ status: 403, headers: { 'content-type': 'application/json' } }
);
}
}
// 3. Honour HTTP Range — every browser <video> player sends Range.
const range = request.headers.get('range');
let r2opts, status = 200, contentRange = null;
const head = await env.SCREENS.head(key);
if (!head) return new Response('Not found', { status: 404 });
const total = head.size;
if (range) {
const m = range.match(/bytes=(\d+)-(\d*)/);
if (m) {
const offset = parseInt(m[1], 10);
const end = Math.min(m[2] ? parseInt(m[2], 10) : total - 1, total - 1);
r2opts = { range: { offset, length: end - offset + 1 } };
status = 206;
contentRange = `bytes ${offset}-${end}/${total}`;
}
}
// 4. Lie about the content type — .mov bytes ARE valid mp4 for H.264+AAC,
// but Chrome/Firefox refuse to play `video/quicktime`. This one line
// fixed playback for 90% of our visitors.
const contentTypeFor = (k) => {
const lower = k.toLowerCase();
if (lower.endsWith('.mov') || lower.endsWith('.mp4') || lower.endsWith('.m4v')) return 'video/mp4';
if (lower.endsWith('.webm')) return 'video/webm';
if (lower.endsWith('.ogg') || lower.endsWith('.ogv')) return 'video/ogg';
return 'application/octet-stream';
};
const obj = await env.SCREENS.get(key, r2opts);
if (!obj) return new Response('Not found', { status: 404 });
const h = new Headers();
obj.writeHttpMetadata(h);
h.set('content-type', contentTypeFor(key));
h.set('etag', obj.httpEtag);
h.set('accept-ranges', 'bytes');
if (contentRange) h.set('content-range', contentRange);
h.set('content-length', String(r2opts ? r2opts.range.length : total));
// Free videos can sit in the public CDN cache for a day.
// Pro videos go to private (per-user) cache for an hour.
h.set('cache-control', keyIsFree ? 'public, max-age=86400' : 'private, max-age=3600');
return new Response(obj.body, { status, headers: h });
}Four things in this snippet are the difference between "kind of works" and "actually ships":
isVideoFree(key, env)reads a manifest, not a flag on the file. Per-video free/Pro rules live insaas/src/video-manifest.js— e.g. "Swift Intro: first 11 videos free, rest Pro." Changing access for an entire series is a one-line code change, not a bucket migration.- The Range header is non-optional. Browser
<video>elements always sendRange: bytes=0-. If youreturn new Response(obj.body)without honouring Range, scrubbing is broken and the browser eventually gives up on long files. Returning 206 Partial Content withContent-Rangeis what makes the seek bar work. video/quicktimeis a content-type lie that doesn't help anyone. The bytes inside a typical.movare H.264 video + AAC audio — identical to what's inside an.mp4. Chrome and Firefox refuse to playvideo/quicktime, but happily play the exact same bytes labelledvideo/mp4. One line fixes 90% of "video doesn't play in browser" tickets.- Cache-Control splits public vs private. Free videos can sit in Cloudflare's CDN for any user — same bytes, no authorisation. Pro videos go to
private, max-age=3600so they're cached in the user's browser but never on a shared edge — otherwise a paid response leaks to the next user who hits that cache key.
What this design gives up
- No ABR ladder. Everyone gets the source bitrate. For 1080p tutorial content on broadband this is fine; for a global D2C product targeting mobile in low-bandwidth regions, you'd want Stream.
- No DRM. A logged-in Pro subscriber can right-click → Save As. The 1-hour private cache header limits casual sharing of the URL, but it's not protection against a motivated leaker.
- The Worker proxies every byte. That counts as Workers Subrequests + R2 Class B reads. The numbers stay small at our volume; they would not at YouTube scale.
We accepted all three because the alternative — paying Stream's per-minute delivery on a library that's mostly long-tail back-catalog — was 50–100× more expensive at our usage profile. If our watch time grows another 10× we'll revisit, probably by moving the most-watched series to Stream while keeping the long tail on R2.
Anti-piracy reality check
Cloudflare Stream's documented controls — signed URLs, allowed-origins, hotlinking protection, geo/IP rules, static watermarks — are soft protection. They make casual piracy annoying. They are not Widevine / FairPlay / PlayReady DRM, and they are not forensic per-viewer watermarking.
For ordinary course content, soft protection is enough. For studio-grade DRM (think: licensed films, sports), you're outside Cloudflare's documented surface and into a separate vendor selection.
On the R2-centric path, you get even less for free — just the proxy auth check, short-lived URLs, and private cache headers. If that's a problem for your content, that's a strong reason to move up to Stream.
A clean unlock flow
The single biggest UX win in a paid video product is making the first play after checkout feel instant.
The pattern that works:
- User clicks Subscribe → redirected to Stripe Checkout.
- Checkout
success_urllands them on a page that says "Unlocking your access…" and starts polling a Worker endpoint. - Meanwhile Stripe POSTs
checkout.session.completedto your webhook. The webhook updates the Durable Object entitlement. - The polling endpoint sees the entitlement flip and returns 200 with the first playback URL pre-issued.
- Frontend swaps the "unlocking" UI for the player and starts playback.
This works because the webhook is the truth, the redirect is a hint, and the poll closes the small window between them. The perceived unlock is sub-second; the actual security model never depends on URL parameters.
Decision guide
| If your product is… | Use… | |---|---| | A small/medium course library, you want to ship next week | A. Stream-first | | You already have masters in R2 and don't want to throw that away | B. Hybrid R2 + Stream | | Cost-sensitive, modest watch volume, single source bitrate is fine | C. R2-centric proxy (what SAS ships) | | Studio-grade DRM, forensic watermarking, global mobile audience | Not pure Cloudflare — bring a specialised vendor |
There is no universally best answer. The R2-centric option that powers simpleappshipper.com would be wrong for a product 10× the size or 10× more sensitive to piracy. Stream-first would be wrong for a side-project paying $50/month. Pick the architecture that matches the bill you're willing to pay and the engineering surface you're willing to own.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper