Tutorials Build a Course Platform on Cloudflare Chapter 4

The Paywall — Server-Side Gates for Videos and SEO-Safe Article Paywalls

Course PlatformChapter 4 of the Build a Course Platform on Cloudflare24 minMay 29, 2026Intermediate

Chapter 3 ended with a one-liner: isSubscribed(userId, env.DB). This chapter is what you do with it — the policy layer that turns "we know who they are and what they paid for" into a real paid-content site.

There are two paywall flavours and you'll use both:

FlavourForWhere it lives
Hard gateVideos, premium APIs, downloadable filesServer-side — the Worker refuses the request
Soft gateArticles, written tutorials, blog contentClient-side — full content in HTML for SEO, CSS-blur overlay for humans

Mixing the two correctly is the whole craft. We'll do them in that order.

The Hard Gate — Three Status Codes

For anything where a client could keep the bytes once it gets them (video files, downloadable PDFs, JSON from a premium API), the only safe enforcement is at the Worker. Client-side gates can be bypassed in DevTools in 10 seconds — they're decoration, not security.

The Worker's job is to return one of exactly three status codes:

CodeMeaningWhat the client should do
200 / 206You're allowedRender / stream the content
401Not signed inShow "Sign in" CTA, redirect to /auth/google
402 Payment RequiredSigned in, but no active subscriptionShow paywall + Stripe Payment Link

402 is the genuinely under-used HTTP status code — it exists for exactly this case and it's the cleanest signal a frontend can switch on. The full gate, copy-pasted from Ch 2's video Worker:

const user = await verifyJWT(readSessionCookie(req), env.JWT_SECRET);
const isFree = isFreeVideo(videoKey);                    // see manifest below
const subscribed = user && (await isSubscribed(user.id, env.DB));
 
if (!isFree && !user)        return new Response("Sign in",  { status: 401 });
if (!isFree && !subscribed)  return new Response("Pro only", { status: 402 });
// otherwise: stream from R2 (Ch 2)

Three lines. That's the entire video paywall.

The video manifest pattern (declarative free-vs-Pro)

Where does isFreeVideo come from? You could query D1 on every request, but for a course site whose free/Pro split rarely changes, declare it in code and skip the database entirely:

// src/video-manifest.js
export const VIDEO_RULES = {
  // Getting Started — all free
  "getting-started/01-welcome.mp4": "free",
  "getting-started/02-tour.mp4":    "free",
  // Swift Intro — first chapter free, rest Pro
  "swift-intro/01-hello.mp4":       "free",
  "swift-intro/02-variables.mp4":   "pro",
  "swift-intro/03-loops.mp4":       "pro",
  // ...
};
 
export const isFreeVideo = (key) => VIDEO_RULES[key] === "free";

Why declarative beats a DB table for this:

Switch to a DB-backed rule when free/Pro becomes variable — A/B testing, per-user overrides, time-limited promotions. Until then, a manifest is correct.

Loading diagram…

Figure 1 — The hard-gate decision tree. Three booleans, three responses. Everything else in this chapter is variations on this picture.

The Soft Gate — A Count-Based Article Paywall That's SEO-Safe

Written articles have a different problem from videos. You want them on the public internet — indexed by Google, sharable on social, readable by anyone arriving from a search — but you also want them to convert eventually into paying readers. Hard-gating them at the Worker breaks SEO; not gating them at all gives away the whole product.

The pattern this site (and many others) uses:

Keep the full article in the HTML. Track reads in localStorage. After N free reads, apply a CSS blur to everything past the first section and overlay a "subscribe to keep reading" panel.

That gets you:

The 30-line implementation

Three pieces — a CSS rule, a JS counter, a "you're a subscriber now" marker.

/* Hide content past the second <h2> when locked */
body[data-paywall="locked"] article > *:nth-of-type(n+8) {
  filter: blur(6px);
  pointer-events: none;
  user-select: none;
}
body[data-paywall="locked"] #paywall-overlay { display: block; }
<!-- The overlay (hidden by default; shown via the CSS rule) -->
<div id="paywall-overlay" style="display:none; position:fixed; bottom:0; ...">
  <h2>You've read 5 free articles this month</h2>
  <p>Subscribe for $7.99/mo to keep reading.</p>
  <a href="https://checkout.example.com/pro">Subscribe</a>
  <button onclick="restore()">Already subscribed?</button>
</div>
// /js/paywall.js — runs on every article page
(function () {
  const KEY_COUNT = "sas_paywall_count";
  const KEY_SUB   = "sas_subscriber";
  if (localStorage.getItem(KEY_SUB) === "true") return;     // bypass for subscribers
 
  const n = (+localStorage.getItem(KEY_COUNT) || 0) + 1;
  localStorage.setItem(KEY_COUNT, n);
 
  if (n > 5) document.body.dataset.paywall = "locked";
})();

That's the whole client-side paywall. Five free articles per browser, then the gate appears. No server round-trip for non-subscribers, no flash of unrestricted content because the blur is applied before the visible viewport renders.

Marking the visitor as a subscriber

The other half of the trick: after a successful Stripe checkout, Stripe redirects to a /tutorials/premium (or similar) page on your domain. That page's only job is:

// /tutorials/premium — runs on the post-Stripe redirect
localStorage.setItem("sas_subscriber", "true");
localStorage.removeItem("sas_paywall_count");
window.location.href = "/account";

Now their browser knows. Every article they hit from this point bypasses the gate. The localStorage flag is advisory — it gates the UX, not the server. For server-protected resources (videos), the real check is the JWT cookie + D1, which is unforgeable.

The Free-Preview Pattern (a Conversion Lever)

Every course in your library should have at least one free chapter — the open hook that lets a stranger see the production quality before paying. The same VIDEO_RULES manifest handles this naturally; you just tag the first item per course "free". No special code path required.

For articles, you can do the equivalent: the first article in each series is always free regardless of the count (special-case it in the paywall JS by checking a data-free-preview attribute on the article element). That keeps the top-of-funnel free without giving away the whole series.

Edge Cases — The Five Things People Forget

CaseWhat to do
User cancels subscriptionStripe fires customer.subscription.deleted → webhook sets status. isSubscribed returns false on the very next request — no extra code.
User on a second deviceSign in with Google again → cookie set → isSubscribed re-checks D1 → instant access. The "Restore" button is just "Sign in with Google."
Refunds / chargebacksStripe fires charge.refunded / charge.dispute.created. Handle these like cancellations.
Trial periodsStripe handles them — the subscription's status is "trialing" until trial ends. Your isSubscribed already accepts that status; nothing to change.
Gift codes / promotional accessDon't reach for a parallel table immediately. Stripe Coupons + checkout.session.completed handle most cases. Add a tiny entitlements table only when you have something Stripe can't model.

The Mac App On Top (the SimpleAppShipper-Specific Edge)

If you ship a desktop or mobile app alongside the website (as this project does — a Mac app with a paid web subscription), the app needs to know subscription state. The clean pattern:

  1. App authenticates the same Google account.
  2. App calls a thin Worker endpoint: GET /api/subscription/status with the JWT cookie/header.
  3. Worker returns { subscribed: true, tier: "pro" } after running isSubscribed.

The bonus trick: a custom URL scheme (yourapp://paid) wired to a handler that refreshes entitlement state immediately. The post-Stripe-success page on the web redirects to that scheme, the app catches it, hits /api/subscription/status, updates its UI to "Pro" within a second of payment. (This site does it as simpleappshipper://paidStripeStoreManager.handlePaymentSuccess().) Users feel like the subscription "just worked"; you wrote ten lines.

The Three Things People Forget Server-Side

Mental Model — Three Sentences

  1. The hard gate is three booleans returning three HTTP status codesisFreeVideo / user / subscribed200 / 401 / 402 — enforced server-side because client-side gates can be bypassed.
  2. The soft article gate is localStorage + CSS blur — full content stays in the HTML so Google indexes you, the counter ticks per read, and a single sas_subscriber=true flag (set after a Stripe success redirect) bypasses everything.
  3. Cancel / refund / multi-device "just work" because the source of truth is Stripe's webhook → D1's subscriptions row → isSubscribed, and every gate calls that one function.

Try It Yourself (20 Minutes)

  1. Define a five-row VIDEO_RULES manifest with three free and two Pro keys. Wire it into Ch 2's Worker. Test with curl: curl -i https://yoursite/v/<free-key> → 200; curl -i https://yoursite/v/<pro-key> (no cookie) → 401; (with subscriber cookie) → 200.
  2. Add the 30-line client paywall to any article page. Read the article six times (refresh the page) and watch the gate kick in on the 6th.
  3. Open DevTools → Application → Local Storage. Set sas_subscriber to true. Refresh — paywall gone. Delete it — gate's back.
  4. Use the Stripe CLI to fire a fake customer.subscription.deleted webhook at your local Worker. Confirm the D1 row updates and isSubscribed flips to false.
  5. Sign in on a second browser. Hit a premium video URL. Confirm it works — Stripe + D1 + the cookie carried the entitlement across.

Where This Lands in the Series

That's the whole Build a Course Platform on Cloudflare series:

  1. The Stack & The Bill — the architecture and the $0 → $10 → $80/mo math.
  2. Streaming Video on R2 — the $0-egress play, encoding, byte-range, the Worker proxy.
  3. Auth + Stripe — Google OAuth, JWT, Payment Links, webhooks, isSubscribed.
  4. The Paywall — hard gates for videos, soft gates for articles, edge cases (this chapter).

You can now ship the same shape of site this one runs on, for under $15/month all-in, fully on Cloudflare with two external services (Google for identity, Stripe for money). Every primitive ties back to its from-scratch chapter in the web series and the Cloudflare Feature Focus for the deeper Workers/R2/D1 mechanics. Build it, launch it, and the next month's bill will be a screenshot worth sharing.

Ch 3: Auth + Stripe SubscriptionsComing Soon →
CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

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
5 free articles remainingSubscribe for unlimited access