Tutorials Build a Course Platform on Cloudflare Chapter 3

Auth + Stripe Subscriptions — Who They Are & What They Paid For

Course PlatformChapter 3 of the Build a Course Platform on Cloudflare30 minMay 29, 2026Intermediate

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

Loading diagram…

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

  1. Visitor clicks "Sign in with Google." Your Worker handles GET /auth/google and 302-redirects to https://accounts.google.com/o/oauth2/v2/auth?... with your client ID and a redirect URI of https://yoursite.com/auth/google/callback.
  2. Google shows the consent screen, the user clicks Allow.
  3. Google redirects to your callback with ?code=.... Your Worker handles GET /auth/google/callback.
  4. Worker exchanges the code for tokens via a POST to https://oauth2.googleapis.com/token. The response includes an id_token (a JWT signed by Google) with the user's email + sub (a stable Google user ID).
  5. Worker verifies the id_token, upserts a row in users, 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:

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:

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

ChannelFeeNet to youAnnual 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

  1. 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 HttpOnly Secure SameSite=Lax session cookie.
  2. 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.
  3. Every premium request is the conjunction of those two functionsawait verifyJWT(cookie) then await isSubscribed(user.id) — and that's the entire gate-layer surface of the app.

Try It Yourself (25 Minutes)

  1. In Google Cloud Console, create an OAuth 2.0 Client ID for "Web application" and add http://localhost:8787/auth/google/callback as a redirect URI.
  2. In Stripe (test mode), create a recurring Product + Price and a Payment Link. Copy the URL.
  3. wrangler init a new project. Add bindings for D1 (with the schema above) and KV.
  4. Implement /auth/google and /auth/google/callback to do the OAuth dance and set the JWT cookie. Sign in and inspect the cookie in DevTools.
  5. Implement /api/stripe/webhook. Use stripe trigger checkout.session.completed (Stripe CLI) to fire a fake event at your local Worker and watch the subscriptions row appear in D1.
  6. Add the isSubscribed function and await 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 2: Streaming Video on R2Ch 4: The Paywall — Server-Side Gates
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