Chapter 2's Worker had two lines like this:
const user = session && (await verifyJWT(session, env.JWT_SECRET));
const subscribed = user && (await isSubscribed(user.id, env.DB));This chapter implements those two lines. They are the gate. Every premium request your Worker handles boils down to (1) who is this person? and (2) what have they paid for? — verifyJWT answers the first; isSubscribed answers the second. Wire them once and the whole rest of the app gets to pretend auth and billing are simple.
We'll do them together because they share a database table and a security mindset: trust nothing, verify everything, and store the smallest possible thing.
The Two-Flow Architecture
Figure 1 — Two independent flows touching the same D1. The solid path is sign-in (identity → users row + cookie). The dashed path is paying (Stripe checkout → webhook → subscriptions row). The two flows never need to know about each other; they meet only in D1.
Part 1: Google OAuth in a Worker
The from-scratch walkthrough is web Ch 12 and the session/cookie/JWT theory is web Ch 11. This section assumes both and focuses on the course-platform specifics.
The five-step dance
- Visitor clicks "Sign in with Google." Your Worker handles
GET /auth/googleand 302-redirects tohttps://accounts.google.com/o/oauth2/v2/auth?...with your client ID and a redirect URI ofhttps://yoursite.com/auth/google/callback. - Google shows the consent screen, the user clicks Allow.
- Google redirects to your callback with
?code=.... Your Worker handlesGET /auth/google/callback. - Worker exchanges the code for tokens via a POST to
https://oauth2.googleapis.com/token. The response includes anid_token(a JWT signed by Google) with the user's email +sub(a stable Google user ID). - Worker verifies the
id_token, upserts a row inusers, mints its own JWT with the user's id + email, and sets it as an HttpOnly cookie. Done — the cookie now identifies this person on every subsequent request.
Two security non-negotiables
Minting your own JWT (no Node crypto, no bcrypt)
bcrypt doesn't run on Workers; the standard Node crypto package isn't available. Use the Web Crypto API instead — it's built into the Workers runtime, and HS256 JWT signing fits in 20 lines:
async function signJWT(payload, secret) {
const enc = new TextEncoder();
const b64url = (s) =>
btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"]
);
const sig = await crypto.subtle.sign(
"HMAC", key, enc.encode(`${header}.${body}`)
);
const sigB64 = b64url(String.fromCharCode(...new Uint8Array(sig)));
return `${header}.${body}.${sigB64}`;
}verifyJWT is the mirror of this — split on ., recompute the HMAC, constant-time compare. Store JWT_SECRET via wrangler secret put JWT_SECRET so it's never in source.
The cookie that carries the session
Set the JWT as a cookie on the callback response:
const jwt = await signJWT({ sub: userId, email, exp: now + 60 * 60 * 24 * 30 }, env.JWT_SECRET);
return new Response(null, {
status: 302,
headers: {
Location: "/dashboard",
"Set-Cookie": [
`session=${jwt}`,
"HttpOnly", // JS can't read it (XSS-safe)
"Secure", // HTTPS only
"SameSite=Lax", // sent on top-level navigations; protects vs CSRF
"Path=/",
`Max-Age=${60 * 60 * 24 * 30}`, // 30 days
].join("; "),
},
});Every flag matters. Drop HttpOnly and any injected <script> can read the cookie. Drop Secure and it travels in plaintext on HTTP. Drop SameSite=Lax and CSRF gets harder to defend.
Part 2: Stripe Subscriptions
Two design decisions before we touch code:
- Use Payment Links, not a custom checkout. Stripe generates a hosted checkout URL. Your "Subscribe" button is
<a href="https://checkout.example.com/pro">Subscribe</a>in sample code, while real product code should import its checkout URL from a central constant such asSTRIPE_LINKS.proMonthly. Stripe handles cards, 3-D Secure, tax, receipts — your Worker never sees a card number (= no PCI scope). For comparison this is exactly how the site you're reading works (STRIPE_LINKS.proMonthlyconstant). - The webhook is the source of truth. The success-page redirect after checkout is a UX nicety; the actual "this user is now subscribed" signal comes from Stripe's signed webhook to your Worker. Build the webhook first; the redirect is decoration.
The minimum D1 schema
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Google `sub` claim
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY, -- Stripe subscription ID
user_id TEXT NOT NULL REFERENCES users(id),
status TEXT NOT NULL, -- active | trialing | past_due | canceled | unpaid
current_period_end INTEGER NOT NULL, -- unix seconds
stripe_customer_id TEXT,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX subscriptions_user_id ON subscriptions(user_id);That's the entire database side of subscriptions. Everything else lives in Stripe.
The webhook handler
The whole thing in one Worker route, with the bits that matter highlighted:
// POST /api/stripe/webhook
async function handleStripeWebhook(req, env) {
const sig = req.headers.get("Stripe-Signature");
const body = await req.text(); // raw bytes — DON'T JSON.parse first
if (!(await verifyStripeSig(body, sig, env.STRIPE_WEBHOOK_SECRET))) {
return new Response("Bad signature", { status: 400 });
}
const evt = JSON.parse(body);
switch (evt.type) {
case "checkout.session.completed":
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = evt.data.object;
const userId = sub.metadata?.user_id; // you set this when creating the link
if (!userId) break;
await env.DB.prepare(`
INSERT INTO subscriptions
(id, user_id, status, current_period_end, stripe_customer_id, updated_at)
VALUES (?, ?, ?, ?, ?, unixepoch())
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
current_period_end = excluded.current_period_end,
updated_at = unixepoch()
`).bind(sub.id, userId, sub.status, sub.current_period_end, sub.customer).run();
break;
}
case "invoice.payment_failed":
// optional: email the user, flag the account
break;
}
return new Response("ok");
}A few non-obvious points:
- Use the raw body for signature verification.
req.text()once, thenJSON.parseafter the check. Re-serializing reorders keys and breaks the HMAC. metadata.user_idis how you connect a Stripe subscription back to a user. Stripe lets you attach metadata when generating a Payment Link client_reference or via the Customer; pass your user's id.ON CONFLICT(id) DO UPDATEmakes the handler idempotent — Stripe will retry webhooks if you 5xx, and you don't want duplicate rows. Same event arriving twice = same final state.
The isSubscribed function the rest of the app calls
export async function isSubscribed(userId, db) {
const row = await db.prepare(`
SELECT status, current_period_end
FROM subscriptions
WHERE user_id = ?
ORDER BY updated_at DESC LIMIT 1
`).bind(userId).first();
if (!row) return false;
if (!["active", "trialing"].includes(row.status)) return false;
return row.current_period_end > Math.floor(Date.now() / 1000);
}That single function is the entitlement check the entire app uses. Ch 2's video Worker called it; Ch 4's article paywall will call it; any future "Pro-only feature" calls it. One function, one query, one source of truth.
Self-service: the customer portal
Stripe gives you a hosted billing portal so subscribers can update their card, see invoices, and cancel — without you writing any UI. Generate a portal session URL from a Worker route and redirect:
// GET /account/billing
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: "https://yoursite.com/account",
});
return Response.redirect(session.url, 302);That's the full self-service flow. Total custom UI: zero.
Why Not Apple In-App Purchase? The Hard Number
If you ever distribute your course content through an iOS app, Apple forces IAP — about 30% off the top. Stripe on the web is around 3%. The arithmetic on a realistic course-site shape:
1,000 active subscribers × $10/month × 12 months = $120,000 / year gross
| Channel | Fee | Net to you | Annual fee paid |
|---|---|---|---|
| Stripe (web direct) | ~3% | $116,400 | $3,600 |
| Apple IAP | ~30% | $84,000 | $36,000 |
That's a $32,400 / year difference — a whole hire, or a full year of expenses for the rest of the stack put together. The course-site pattern of "free-download Mac app + web subscription" (which this project uses) exists almost entirely to avoid this tax. (See Ship iOS Ch 1 for the iOS-specific side of the story.)
Mental Model — Three Sentences
- Google OAuth answers "who are you?" via the standard redirect dance, ending with a JWT you signed yourself (HS256 via Web Crypto, no bcrypt, no Node) set as an
HttpOnlySecureSameSite=Laxsession cookie. - Stripe Payment Links + a signed webhook answers "what did they pay for?" — the cookie identifies the user, the webhook upserts their subscription row in D1, and
isSubscribed(userId)is a one-line lookup the rest of the app uses. - Every premium request is the conjunction of those two functions —
await verifyJWT(cookie)thenawait isSubscribed(user.id)— and that's the entire gate-layer surface of the app.
Try It Yourself (25 Minutes)
- In Google Cloud Console, create an OAuth 2.0 Client ID for "Web application" and add
http://localhost:8787/auth/google/callbackas a redirect URI. - In Stripe (test mode), create a recurring Product + Price and a Payment Link. Copy the URL.
wrangler inita new project. Add bindings for D1 (with the schema above) and KV.- Implement
/auth/googleand/auth/google/callbackto do the OAuth dance and set the JWT cookie. Sign in and inspect the cookie in DevTools. - Implement
/api/stripe/webhook. Usestripe trigger checkout.session.completed(Stripe CLI) to fire a fake event at your local Worker and watch thesubscriptionsrow appear in D1. - Add the
isSubscribedfunction andawait isSubscribed(user.id, env.DB)somewhere visible — that's the line you're now allowed to write anywhere in your app.
Where This Lands in the Series
Identity and entitlement are now solved. Every Worker has user and subscribed — the rest is policy. Ch 4 puts those two booleans to work:
- Ch 4 — The Paywall: server-side gates for video routes (the
ifstatements from Ch 2's diagram), the count-based article paywall this site uses (free for 5 reads, CSS-blur after that, full content stays in DOM for SEO), free-preview patterns, and the gotchas around restoring access after cancellation.
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