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:
| Flavour | For | Where it lives |
|---|---|---|
| Hard gate | Videos, premium APIs, downloadable files | Server-side — the Worker refuses the request |
| Soft gate | Articles, written tutorials, blog content | Client-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:
| Code | Meaning | What the client should do |
|---|---|---|
| 200 / 206 | You're allowed | Render / stream the content |
| 401 | Not signed in | Show "Sign in" CTA, redirect to /auth/google |
| 402 Payment Required | Signed in, but no active subscription | Show 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:
- Zero-query lookup. A
Mapread is a few nanoseconds; a D1 query is single-digit milliseconds. - Deploys with the code. Changes go through
git, code review, andwrangler deploy— same process as the rest of your app. - Trivially auditable. Look at one file to see what's free and what's not.
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.
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:
- Full content in the rendered HTML → Googlebot indexes the whole article (it doesn't run the gate-deciding JavaScript before crawling, and even if it did, you can
<meta name="robots" content="...">it appropriately). - Social previews work — when someone tweets the URL, the unfurl shows the full description.
- First-time visitors read freely — no friction on the very interaction that earns trust.
- Returning visitors hit the gate — at which point they've already signalled interest.
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
| Case | What to do |
|---|---|
| User cancels subscription | Stripe fires customer.subscription.deleted → webhook sets status. isSubscribed returns false on the very next request — no extra code. |
| User on a second device | Sign in with Google again → cookie set → isSubscribed re-checks D1 → instant access. The "Restore" button is just "Sign in with Google." |
| Refunds / chargebacks | Stripe fires charge.refunded / charge.dispute.created. Handle these like cancellations. |
| Trial periods | Stripe handles them — the subscription's status is "trialing" until trial ends. Your isSubscribed already accepts that status; nothing to change. |
| Gift codes / promotional access | Don'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:
- App authenticates the same Google account.
- App calls a thin Worker endpoint:
GET /api/subscription/statuswith the JWT cookie/header. - Worker returns
{ subscribed: true, tier: "pro" }after runningisSubscribed.
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://paid → StripeStoreManager.handlePaymentSuccess().) Users feel like the subscription "just worked"; you wrote ten lines.
The Three Things People Forget Server-Side
Cache-Control: privateon every premium response. Without it, a misconfigured CDN could cache a Pro video URL and serve it to non-Pro viewers from cache.- Verify webhook signatures, every time. A Stripe webhook is just an HTTP POST — anything on the internet can hit your endpoint pretending to be Stripe. The signature is the only thing that proves provenance.
- Don't store what Stripe already does. Stripe knows the card, the address, the billing history. You need: user id, subscription status, period end, customer id. That's it. GDPR-wise, less is more.
Mental Model — Three Sentences
- The hard gate is three booleans returning three HTTP status codes —
isFreeVideo/user/subscribed→200/401/402— enforced server-side because client-side gates can be bypassed. - 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 singlesas_subscriber=trueflag (set after a Stripe success redirect) bypasses everything. - Cancel / refund / multi-device "just work" because the source of truth is Stripe's webhook → D1's
subscriptionsrow →isSubscribed, and every gate calls that one function.
Try It Yourself (20 Minutes)
- Define a five-row
VIDEO_RULESmanifest 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. - 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.
- Open DevTools → Application → Local Storage. Set
sas_subscribertotrue. Refresh — paywall gone. Delete it — gate's back. - Use the Stripe CLI to fire a fake
customer.subscription.deletedwebhook at your local Worker. Confirm the D1 row updates andisSubscribedflips to false. - 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:
- The Stack & The Bill — the architecture and the $0 → $10 → $80/mo math.
- Streaming Video on R2 — the $0-egress play, encoding, byte-range, the Worker proxy.
- Auth + Stripe — Google OAuth, JWT, Payment Links, webhooks,
isSubscribed. - 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.
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