Build a Hugo static site whose /premium/ section is locked behind a Stripe subscription, with the access decision made by a Cloudflare Worker at the edge. Free posts stay cached and public. A request for a premium page runs the Worker first: it checks a signed session cookie and confirms the subscription is still active in KV before it hands back the file. Payment, activation, and cancellation flow through Stripe Checkout and a webhook.

The whole site is one Cloudflare Worker with Static Assets: the built Hugo output is uploaded as assets, and the Worker runs ahead of those assets only for the paths you choose with run_worker_first.

What you will build

  • A Hugo site with free posts under /posts/ and gated posts under /premium/.
  • A Worker that gates /premium/*, verifies an HMAC-signed cookie, and serves the static file only for active subscribers.
  • Stripe Checkout in subscription mode, an activation endpoint that mints the cookie, and a signature-verified webhook that flips access on cancellation.
  • Unit tests for the cookie and Stripe-signature logic that run in plain Node.

Prerequisites

  • Hugo (extended) 0.146 or later — install guide. This tutorial was validated with 0.163. The new template layout (templates directly under layouts/) needs 0.146+.
  • Node.js 20 or later (validated on 26). Node 20+ ships globalThis.crypto (Web Crypto) and btoa/atob, which the Worker code and its tests use.
  • npm 9 or later.
  • Wrangler 4.20 or later — installed as a dev dependency below. Route patterns in run_worker_first require 4.20+.
  • A Cloudflare account (free plan is fine) for the deploy step.
  • A Stripe account in test mode, with one recurring Price created. You need the Price id (price_...), your secret key (sk_test_...), and later a webhook signing secret (whsec_...).
  • A Unix-like shell (macOS, Linux, or WSL).

Run every command from the project root you create in Step 1.

Step 1: Create the project structure

Create the file

mkdir -p hugo-stripe-paywall-cloudflare-workers
touch hugo-stripe-paywall-cloudflare-workers/.gitignore

Add the code: hugo-stripe-paywall-cloudflare-workers/.gitignore

# Hugo build output
/public/
/resources/_gen/
.hugo_build.lock

# Node / Worker
node_modules/
dist/
.wrangler/

# Local secrets (never commit real keys)
.dev.vars

# OS / editor / logs
.DS_Store
*.log
tmp/

Detailed breakdown

  • /public/ and /resources/_gen/: Hugo build artifacts. public/ is regenerated by hugo and uploaded to Cloudflare on deploy, so it never belongs in git.
  • node_modules/, dist/, .wrangler/: npm dependencies, any bundler output, and Wrangler’s local state (including the local KV store used by wrangler dev).
  • .dev.vars: holds your local Stripe and session secrets for wrangler dev. This line is the reason the file is safe to keep on disk; a committed key is a leaked key.
  • Create this file first so nothing generated by later steps is ever staged by accident.

Step 2: Configure the Hugo site

Create the file

touch hugo-stripe-paywall-cloudflare-workers/hugo.toml

Add the code: hugo-stripe-paywall-cloudflare-workers/hugo.toml

baseURL = "https://example.com/"
title = "Edge Paywall Demo"

[languages.en]
locale = "en-US"
label = "English"
weight = 1

# This demo has no tags or categories, so skip the taxonomy templates.
disableKinds = ["taxonomy", "term"]

# Only emit HTML (no RSS) to keep the build output small and predictable.
[outputs]
home = ["HTML"]
section = ["HTML"]

[params]
description = "A Hugo site with a Stripe-gated premium section, served from Cloudflare Workers."

# The subscribe page embeds a raw <form> that posts to the Worker, so allow
# inline HTML in Markdown.
[markup.goldmark.renderer]
unsafe = true

Detailed breakdown

  • baseURL: a placeholder. Because every link the site emits is root-relative (/premium/...), the paywall works the same on your real domain or on localhost during development.
  • [languages.en] with locale and label: the current, non-deprecated way to declare the site language. Older tutorials use top-level languageCode and languageName, both of which now emit deprecation warnings.
  • disableKinds: turns off the taxonomy and term pages. Without this, Hugo warns that it has no template for the categories/tags list pages you never asked for.
  • [outputs]: restricts the home and section pages to HTML so the build produces a small, predictable file set. RSS is off.
  • [markup.goldmark.renderer] unsafe = true: lets the subscribe page embed a raw <form> element in Markdown. Goldmark strips inline HTML by default.

Step 3: Write the templates

Hugo 0.146+ looks up templates directly under layouts/: baseof.html wraps every page, home.html renders the front page, section.html renders list pages such as /posts/ and /premium/, and page.html renders a single post.

Create the file

mkdir -p hugo-stripe-paywall-cloudflare-workers/layouts
touch hugo-stripe-paywall-cloudflare-workers/layouts/baseof.html

Add the code: hugo-stripe-paywall-cloudflare-workers/layouts/baseof.html

<!DOCTYPE html>
<html lang="{{ .Site.Language.Lang }}">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }}</title>
    <link rel="stylesheet" href="/css/style.css" />
  </head>
  <body>
    <header class="site-header">
      <a class="brand" href="/">{{ .Site.Title }}</a>
      <nav>
        <a href="/posts/">Free posts</a>
        <a href="/premium/">Premium</a>
        <a href="/subscribe/">Subscribe</a>
      </nav>
    </header>
    <main>
      {{ block "main" . }}{{ end }}
    </main>
    <footer class="site-footer">
      <p>{{ .Site.Params.description }}</p>
    </footer>
  </body>
</html>

Detailed breakdown

  • {{ block "main" . }}{{ end }}: the hole each page-specific template fills with {{ define "main" }}. This is Hugo’s base-template mechanism, so the header, footer, and stylesheet link live in one place.
  • .Site.Language.Lang: the modern accessor for the language code; .Site.LanguageCode is deprecated.
  • The nav links to /premium/ on purpose. That link hits the gate, which is exactly what you want to test later.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/layouts/home.html

Add the code: hugo-stripe-paywall-cloudflare-workers/layouts/home.html

{{ define "main" }}
  <section class="hero">
    <h1>{{ .Site.Title }}</h1>
    {{ .Content }}
  </section>

  <section class="listing">
    <h2>Free posts</h2>
    <ul>
      {{ range where .Site.RegularPages "Section" "posts" }}
        <li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
      {{ end }}
    </ul>
  </section>

  <section class="listing">
    <h2>Premium posts <span class="lock">🔒</span></h2>
    <ul>
      {{ range where .Site.RegularPages "Section" "premium" }}
        <li>
          <a href="{{ .RelPermalink }}">{{ .Title }}</a>
          <span class="badge">members only</span>
        </li>
      {{ end }}
    </ul>
    <p><a class="cta" href="/subscribe/">Subscribe to unlock →</a></p>
  </section>
{{ end }}

Detailed breakdown

  • The home page lists premium titles publicly as teasers, but the links point into the gated section. Listing titles is a marketing choice, not a leak — the article bodies are still gated. If you want the titles hidden too, drop the second range.
  • where .Site.RegularPages "Section" "premium": filters all pages down to those in the premium section, which maps to the content/premium/ folder.
  • .RelPermalink: emits a root-relative URL like /premium/scaling-playbook/, the same path shape the Worker gates.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/layouts/section.html
touch hugo-stripe-paywall-cloudflare-workers/layouts/page.html
touch hugo-stripe-paywall-cloudflare-workers/layouts/404.html

Add the code: hugo-stripe-paywall-cloudflare-workers/layouts/section.html

{{ define "main" }}
  <article class="section">
    <h1>{{ .Title }}</h1>
    {{ .Content }}
    <ul>
      {{ range .Pages }}
        <li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
      {{ end }}
    </ul>
  </article>
{{ end }}

Add the code: hugo-stripe-paywall-cloudflare-workers/layouts/page.html

{{ define "main" }}
  <article class="post">
    <h1>{{ .Title }}</h1>
    {{ if eq .Section "premium" }}<p class="badge">members only</p>{{ end }}
    {{ .Content }}
  </article>
{{ end }}

Add the code: hugo-stripe-paywall-cloudflare-workers/layouts/404.html

{{ define "main" }}
  <article class="post">
    <h1>Not found</h1>
    <p>That page does not exist. Try the <a href="/">home page</a>.</p>
  </article>
{{ end }}

Detailed breakdown

  • section.html renders both /posts/ and /premium/ list pages. The /premium/ list page is itself gated by the Worker, so an unsubscribed visitor never reaches it.
  • page.html renders a single post and stamps a “members only” badge on premium pages via {{ if eq .Section "premium" }}.
  • 404.html produces public/404.html. The Worker config points not_found_handling at this file, so unmatched paths return a styled 404 rather than a blank one.

Step 4: Add content

Each folder under content/ becomes a URL section. The content/premium/ folder is the one the Worker will gate.

Create the file

mkdir -p hugo-stripe-paywall-cloudflare-workers/content/posts
mkdir -p hugo-stripe-paywall-cloudflare-workers/content/premium
touch hugo-stripe-paywall-cloudflare-workers/content/_index.md
touch hugo-stripe-paywall-cloudflare-workers/content/posts/_index.md
touch hugo-stripe-paywall-cloudflare-workers/content/posts/getting-started.md
touch hugo-stripe-paywall-cloudflare-workers/content/premium/_index.md
touch hugo-stripe-paywall-cloudflare-workers/content/premium/scaling-playbook.md
touch hugo-stripe-paywall-cloudflare-workers/content/premium/cost-model.md
touch hugo-stripe-paywall-cloudflare-workers/content/subscribe.md

Add the code: hugo-stripe-paywall-cloudflare-workers/content/_index.md

---
title: "Edge Paywall Demo"
---

This site keeps its free posts open to everyone and locks the premium section
behind an active Stripe subscription. A Cloudflare Worker checks a signed
session cookie at the edge before it hands back any file under `/premium/`.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/posts/_index.md

---
title: "Free posts"
---

These posts are readable without a subscription.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/posts/getting-started.md

---
title: "Getting started with edge paywalls"
date: 2026-01-05
---

An edge paywall moves the access decision out of your application and into the
CDN. The request never reaches an origin server; a Worker inspects the cookie,
decides, and either serves the cached file or redirects to a subscribe page.

This post is free. The premium posts cover the parts you would pay for.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/premium/_index.md

---
title: "Premium posts"
---

Every page in this section is gated. Without an active subscription the Worker
redirects here-bound requests to `/subscribe/`.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/premium/scaling-playbook.md

---
title: "The scaling playbook"
date: 2026-01-12
---

This is premium content. If you can read this in a browser without being
redirected, your session cookie passed verification at the edge.

The playbook itself would go here: capacity planning, cache keys, and the
rollout order that keeps a launch from melting your origin.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/premium/cost-model.md

---
title: "A cost model for edge apps"
date: 2026-01-18
---

This is premium content, gated by the same cookie check as the rest of the
section.

The full cost model would break down request pricing, KV reads per request, and
where a signed-cookie check saves you a storage lookup on the hot path.

Add the code: hugo-stripe-paywall-cloudflare-workers/content/subscribe.md

---
title: "Subscribe"
---

A subscription unlocks every post in the premium section. Checkout is handled by
Stripe; you will be redirected back here and your access is granted through a
signed cookie.

<form method="POST" action="/api/checkout">
  <button class="cta" type="submit">Subscribe with Stripe →</button>
</form>

Already subscribed on this device? Visit any <a href="/premium/">premium post</a>
directly.

Detailed breakdown

  • _index.md files give a section its own front matter and body. The content/premium/_index.md file is what makes /premium/ a real list page.
  • The two premium posts state plainly that they are gated. When you test the full flow, seeing this text in the browser is the signal that the cookie verified.
  • subscribe.md posts a form to /api/checkout. A plain HTML form needs no JavaScript: the browser sends a POST, the Worker creates a Stripe Checkout Session, and returns a 303 redirect to Stripe’s hosted page.

Step 5: Add styling and build the site

Create the file

mkdir -p hugo-stripe-paywall-cloudflare-workers/static/css
touch hugo-stripe-paywall-cloudflare-workers/static/css/style.css

Add the code: hugo-stripe-paywall-cloudflare-workers/static/css/style.css

:root {
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  line-height: 1.55;
  color: #1a1a1a;
}
body { max-width: 44rem; margin: 0 auto; padding: 1.5rem; }
.site-header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #ddd; padding-bottom: .75rem; }
.site-header .brand { font-weight: 700; text-decoration: none; color: inherit; }
.site-header nav a { margin-left: 1rem; }
a { color: #1558d6; }
.badge { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; background: #f1f1f1; border-radius: .4rem; padding: .1rem .4rem; margin-left: .4rem; }
.cta { display: inline-block; background: #635bff; color: #fff; padding: .6rem 1rem; border: none; border-radius: .5rem; font-size: 1rem; text-decoration: none; cursor: pointer; }
.lock { font-size: 1rem; }
.site-footer { margin-top: 3rem; border-top: 1px solid #ddd; padding-top: .75rem; color: #666; font-size: .85rem; }

Detailed breakdown

  • Files under static/ are copied verbatim into public/, so this lands at /css/style.css, matching the <link> in baseof.html.
  • The styling is intentionally small. None of it affects the paywall; it only makes the “members only” badges and the subscribe button legible.

Build the site now to confirm the templates and content are wired correctly:

cd hugo-stripe-paywall-cloudflare-workers
hugo --gc --minify

Expected output (a clean build with no warnings):

Start building sites …

                  │ EN
──────────────────┼────
 Pages            │  9
 ...
 Static files     │  1

Total in 6 ms

Confirm the generated file tree:

find public -type f | sort
public/404.html
public/css/style.css
public/index.html
public/posts/getting-started/index.html
public/posts/index.html
public/premium/cost-model/index.html
public/premium/index.html
public/premium/scaling-playbook/index.html
public/sitemap.xml
public/subscribe/index.html

The premium pages exist as ordinary static HTML under public/premium/. Nothing about the file itself is secret; access control is enforced entirely by the Worker in front of it.

Step 6: Write the paywall logic

The cookie and path logic is pure and runtime-agnostic: it uses only the Web Crypto API and btoa/atob, which exist in both the Workers runtime and Node 20+. That is what lets the unit tests in Step 11 run in plain Node with no Worker emulator.

Create the file

mkdir -p hugo-stripe-paywall-cloudflare-workers/src
touch hugo-stripe-paywall-cloudflare-workers/src/paywall.ts

Add the code: hugo-stripe-paywall-cloudflare-workers/src/paywall.ts

// Pure, runtime-agnostic paywall helpers. Everything here uses the Web Crypto
// API (globalThis.crypto), which exists both in Cloudflare Workers and in
// Node.js 20+, so these functions are unit-testable without a Worker runtime.

export const SESSION_COOKIE = "paywall_session";

/** Session payload embedded in the signed cookie. */
export interface Session {
  /** Stripe customer id this session belongs to. */
  sub: string;
  /** Expiry as a Unix timestamp in seconds. */
  exp: number;
}

/** True for any path that must be gated behind a subscription. */
export function isPremiumPath(pathname: string): boolean {
  return pathname === "/premium" || pathname.startsWith("/premium/");
}

// --- base64url helpers (no padding, URL-safe) -----------------------------

function toBase64Url(bytes: Uint8Array): string {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function fromBase64Url(input: string): Uint8Array {
  const padded = input.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

const encoder = new TextEncoder();
const decoder = new TextDecoder();

async function hmacKey(secret: string): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"],
  );
}

/** Length-independent, byte-wise equality to avoid timing side channels. */
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

/**
 * Serialize a session as `<payload>.<signature>`, where both parts are
 * base64url and the signature is HMAC-SHA256 over the payload segment.
 */
export async function signSession(session: Session, secret: string): Promise<string> {
  const payload = toBase64Url(encoder.encode(JSON.stringify(session)));
  const key = await hmacKey(secret);
  const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
  return `${payload}.${toBase64Url(sig)}`;
}

/**
 * Verify a signed session token. Returns the session when the signature is
 * valid and the token has not expired, otherwise `null`. `now` is the current
 * Unix time in seconds (injectable so tests are deterministic).
 */
export async function verifySession(
  token: string,
  secret: string,
  now: number,
): Promise<Session | null> {
  const dot = token.indexOf(".");
  if (dot < 0) return null;
  const payload = token.slice(0, dot);
  const providedSig = token.slice(dot + 1);

  const key = await hmacKey(secret);
  const expected = new Uint8Array(
    await crypto.subtle.sign("HMAC", key, encoder.encode(payload)),
  );

  let provided: Uint8Array;
  try {
    provided = fromBase64Url(providedSig);
  } catch {
    return null;
  }
  if (!constantTimeEqual(expected, provided)) return null;

  let session: Session;
  try {
    session = JSON.parse(decoder.decode(fromBase64Url(payload)));
  } catch {
    return null;
  }
  if (typeof session.sub !== "string" || typeof session.exp !== "number") return null;
  if (session.exp <= now) return null;
  return session;
}

/** Build a `Set-Cookie` value for the signed session. */
export function sessionCookie(token: string, maxAgeSeconds: number): string {
  return [
    `${SESSION_COOKIE}=${token}`,
    "Path=/",
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
    `Max-Age=${maxAgeSeconds}`,
  ].join("; ");
}

/** Read a single cookie value out of a `Cookie` header. */
export function readCookie(header: string | null, name: string): string | null {
  if (!header) return null;
  for (const part of header.split(";")) {
    const [k, ...rest] = part.trim().split("=");
    if (k === name) return rest.join("=");
  }
  return null;
}

Detailed breakdown

  • isPremiumPath is the single source of truth for what is gated. It matches /premium exactly and anything under /premium/. Note the negative case: /premium-preview/ does not match, because the check is /premium/ with the trailing slash, not a bare prefix.
  • signSession / verifySession implement a compact signed token: base64url(JSON payload) + "." + base64url(HMAC-SHA256). This is the same shape as a JWT’s signed part but without the header, which is all you need for a first-party cookie you both sign and verify.
  • now is a parameter, not a call to Date.now() inside the function. That keeps expiry logic deterministic so a test can assert “expired” without sleeping.
  • constantTimeEqual compares the computed and provided signatures without an early return on the first differing byte. A naive === on hex strings can leak signature bytes through timing; forge attempts should all cost the same.
  • verifySession verifies before it parses. It checks the HMAC over the raw payload segment first, and only then decodes the JSON. A tampered payload fails the signature check and never reaches JSON.parse.
  • Cookie flags: HttpOnly keeps JavaScript from reading the token, Secure keeps it off plaintext HTTP, and SameSite=Lax still sends it on the top-level navigation back from Stripe. Max-Age bounds how long a stolen cookie is useful.

Step 7: Write the Stripe helpers

The Stripe Node SDK depends on Node-only APIs and does not run on Workers, so call the REST API directly with a form-encoded body. Two functions here are pure and tested; the two network functions wrap fetch.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/src/stripe.ts

Add the code: hugo-stripe-paywall-cloudflare-workers/src/stripe.ts

// Minimal Stripe helpers built on fetch and Web Crypto. No SDK: the Stripe
// Node SDK pulls in Node-only APIs, so on Workers you call the REST API
// directly with a form-encoded body.

import { constantTimeEqual } from "./paywall";

const encoder = new TextEncoder();

/** Hex-encoded HMAC-SHA256, the format Stripe uses for webhook signatures. */
export async function hmacSha256Hex(message: string, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
  return [...sig].map((b) => b.toString(16).padStart(2, "0")).join("");
}

/**
 * Verify a `Stripe-Signature` header against the raw request body.
 * The header looks like `t=1699999999,v1=<hex>`; Stripe signs the string
 * `${t}.${payload}`. Returns false on a bad signature or a timestamp outside
 * `toleranceSeconds` (replay protection).
 */
export async function verifyStripeSignature(
  payload: string,
  header: string | null,
  secret: string,
  now: number,
  toleranceSeconds = 300,
): Promise<boolean> {
  if (!header) return false;
  let timestamp = "";
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=");
    if (key === "t") timestamp = value;
    else if (key === "v1") signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return false;

  const age = now - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > toleranceSeconds) return false;

  const expected = await hmacSha256Hex(`${timestamp}.${payload}`, secret);
  const expectedBytes = encoder.encode(expected);
  return signatures.some((sig) => constantTimeEqual(expectedBytes, encoder.encode(sig)));
}

/**
 * Form-encode the body for creating a subscription Checkout Session.
 * Returns an `application/x-www-form-urlencoded` string.
 */
export function checkoutSessionBody(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
}): string {
  const body = new URLSearchParams();
  body.set("mode", "subscription");
  body.set("line_items[0][price]", params.priceId);
  body.set("line_items[0][quantity]", "1");
  body.set("success_url", params.successUrl);
  body.set("cancel_url", params.cancelUrl);
  return body.toString();
}

interface StripeCheckoutSession {
  status?: string;
  payment_status?: string;
  customer?: string | null;
  subscription?: string | null;
}

/** Call the Stripe REST API with secret-key auth and a form body. */
async function stripeRequest(
  path: string,
  secretKey: string,
  init: { method: string; body?: string } = { method: "GET" },
): Promise<Response> {
  return fetch(`https://api.stripe.com${path}`, {
    method: init.method,
    headers: {
      Authorization: `Bearer ${secretKey}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: init.body,
  });
}

/** Create a Checkout Session and return its hosted URL. */
export async function createCheckoutSession(
  secretKey: string,
  params: { priceId: string; successUrl: string; cancelUrl: string },
): Promise<string> {
  const res = await stripeRequest("/v1/checkout/sessions", secretKey, {
    method: "POST",
    body: checkoutSessionBody(params),
  });
  if (!res.ok) throw new Error(`Stripe checkout failed: ${res.status} ${await res.text()}`);
  const data = (await res.json()) as { url?: string };
  if (!data.url) throw new Error("Stripe checkout returned no URL");
  return data.url;
}

/** Fetch a completed Checkout Session so we can trust its result. */
export async function retrieveCheckoutSession(
  secretKey: string,
  sessionId: string,
): Promise<StripeCheckoutSession> {
  const res = await stripeRequest(`/v1/checkout/sessions/${sessionId}`, secretKey);
  if (!res.ok) throw new Error(`Stripe retrieve failed: ${res.status}`);
  return (await res.json()) as StripeCheckoutSession;
}

Detailed breakdown

  • verifyStripeSignature reimplements Stripe’s scheme: parse the header into a timestamp t and one or more v1 signatures, recompute HMAC-SHA256 over ${t}.${payload}, and compare in constant time. Verifying it yourself avoids the Node SDK, which will not run on Workers.
  • The tolerance check rejects a body whose timestamp is more than five minutes from now. That is Stripe’s own recommendation and blocks replay of a captured webhook. Using Math.abs also rejects timestamps implausibly far in the future.
  • verifyStripeSignature accepts multiple v1 values. During a secret rotation Stripe sends more than one; signatures.some(...) passes if any one matches.
  • checkoutSessionBody builds the flattened line_items[0][price] keys Stripe expects in a form body. It is pure, so a test can assert the encoding without a network call.
  • retrieveCheckoutSession is the trust boundary for activation. The browser’s redirect back from Stripe is not proof of payment on its own; the Worker re-fetches the session server-side and checks status and payment_status before granting access.

Step 8: Write the Worker

The Worker is the entry point. It routes the API paths, gates /premium/*, and defers everything else to the static assets binding.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/src/worker.ts

Add the code: hugo-stripe-paywall-cloudflare-workers/src/worker.ts

import {
  SESSION_COOKIE,
  isPremiumPath,
  readCookie,
  sessionCookie,
  signSession,
  verifySession,
} from "./paywall";
import {
  createCheckoutSession,
  retrieveCheckoutSession,
  verifyStripeSignature,
} from "./stripe";

export interface Env {
  // Static assets binding (the built Hugo site in ./public).
  ASSETS: Fetcher;
  // KV namespace holding subscription status, keyed by Stripe customer id.
  SUBSCRIBERS: KVNamespace;
  // Vars.
  STRIPE_PRICE_ID: string;
  // Secrets (set with `wrangler secret put`, or via .dev.vars locally).
  STRIPE_SECRET_KEY: string;
  STRIPE_WEBHOOK_SECRET: string;
  SESSION_SECRET: string;
}

const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days

function redirect(location: string, headers: Record<string, string> = {}): Response {
  return new Response(null, { status: 303, headers: { Location: location, ...headers } });
}

export default {
  async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // API endpoints run before static assets (see run_worker_first).
    if (path === "/api/checkout" && request.method === "POST") {
      return handleCheckout(request, env, url);
    }
    if (path === "/api/activate" && request.method === "GET") {
      return handleActivate(request, env, url);
    }
    if (path === "/api/stripe/webhook" && request.method === "POST") {
      return handleWebhook(request, env);
    }

    // Gate the premium section.
    if (isPremiumPath(path)) {
      const allowed = await hasAccess(request, env);
      if (!allowed) return redirect("/subscribe/");
    }

    // Everything else (and allowed premium requests) falls through to the
    // built site.
    return env.ASSETS.fetch(request);
  },
};

/** Verify the signed cookie, then confirm the subscription is still active. */
async function hasAccess(request: Request, env: Env): Promise<boolean> {
  const token = readCookie(request.headers.get("Cookie"), SESSION_COOKIE);
  if (!token) return false;

  const now = Math.floor(Date.now() / 1000);
  const session = await verifySession(token, env.SESSION_SECRET, now);
  if (!session) return false;

  // The cookie proves identity; KV proves the subscription was not cancelled.
  const status = await env.SUBSCRIBERS.get(`sub:${session.sub}`);
  return status === "active";
}

async function handleCheckout(_request: Request, env: Env, url: URL): Promise<Response> {
  const origin = url.origin;
  const checkoutUrl = await createCheckoutSession(env.STRIPE_SECRET_KEY, {
    priceId: env.STRIPE_PRICE_ID,
    successUrl: `${origin}/api/activate?session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${origin}/subscribe/`,
  });
  return redirect(checkoutUrl);
}

async function handleActivate(_request: Request, env: Env, url: URL): Promise<Response> {
  const sessionId = url.searchParams.get("session_id");
  if (!sessionId) return redirect("/subscribe/");

  const session = await retrieveCheckoutSession(env.STRIPE_SECRET_KEY, sessionId);
  if (session.status !== "complete" || session.payment_status !== "paid" || !session.customer) {
    return redirect("/subscribe/");
  }

  // Record the active subscription and mint a signed cookie.
  await env.SUBSCRIBERS.put(`sub:${session.customer}`, "active");
  const now = Math.floor(Date.now() / 1000);
  const token = await signSession(
    { sub: session.customer, exp: now + SESSION_TTL_SECONDS },
    env.SESSION_SECRET,
  );
  return redirect("/premium/", { "Set-Cookie": sessionCookie(token, SESSION_TTL_SECONDS) });
}

async function handleWebhook(request: Request, env: Env): Promise<Response> {
  const payload = await request.text();
  const now = Math.floor(Date.now() / 1000);
  const valid = await verifyStripeSignature(
    payload,
    request.headers.get("Stripe-Signature"),
    env.STRIPE_WEBHOOK_SECRET,
    now,
  );
  if (!valid) return new Response("invalid signature", { status: 400 });

  const event = JSON.parse(payload) as {
    type: string;
    data: { object: { customer?: string } };
  };
  // Both subscription and invoice events carry the customer id, which is the
  // same key we mint the cookie against in handleActivate.
  const customer = event.data.object.customer;

  if (customer) {
    switch (event.type) {
      case "customer.subscription.deleted":
        await env.SUBSCRIBERS.put(`sub:${customer}`, "inactive");
        break;
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "invoice.paid":
        await env.SUBSCRIBERS.put(`sub:${customer}`, "active");
        break;
    }
  }
  return new Response("ok", { status: 200 });
}

Detailed breakdown

  • fetch is the whole router. API paths are handled explicitly; premium paths run through hasAccess; everything else calls env.ASSETS.fetch(request), which serves the matching static file (or the 404 page). This is the assets binding “defer to static” pattern.
  • hasAccess does two checks, in order. First the HMAC cookie proves the request belongs to a real customer without any storage read. Then a single KV read confirms the subscription has not been cancelled. The cookie handles the hot path cheaply; KV handles revocation.
  • handleActivate never trusts the redirect. Stripe appends its real session id in place of the {CHECKOUT_SESSION_ID} template. The Worker re-fetches that session and only mints a cookie when status is complete and payment_status is paid. Anyone hitting /api/activate with a fake id gets bounced to /subscribe/.
  • The customer id is the join key. Activation writes sub:<customer> and signs the cookie with sub set to that same id, so the webhook can flip the exact record the cookie points at.
  • handleWebhook verifies before it parses, exactly like the cookie path. An unsigned or stale body returns 400 and never touches KV.
  • Status transitions: a deleted subscription writes inactive; created, updated, and paid-invoice events write active. Because hasAccess reads KV on every premium request, a cancellation takes effect on the reader’s next page load even though their cookie is still cryptographically valid.
  • 303, not 302. After a POST to /api/checkout, a 303 tells the browser to follow with a GET, which is what Stripe’s hosted page expects.

Step 9: Add the Node and TypeScript configuration

Create the file

touch hugo-stripe-paywall-cloudflare-workers/package.json
touch hugo-stripe-paywall-cloudflare-workers/tsconfig.json

Add the code: hugo-stripe-paywall-cloudflare-workers/package.json

{
  "name": "hugo-stripe-paywall-cloudflare-workers",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "site": "hugo --gc --minify",
    "dev": "wrangler dev",
    "check": "wrangler deploy --dry-run",
    "test": "vitest run",
    "deploy": "wrangler deploy"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^5.0.0",
    "typescript": "^5.6.0",
    "vitest": "^3.2.7",
    "wrangler": "^4.20.0"
  }
}

Add the code: hugo-stripe-paywall-cloudflare-workers/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "WebWorker"],
    "types": ["@cloudflare/workers-types"],
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

Install the dependencies:

cd hugo-stripe-paywall-cloudflare-workers
npm install

Detailed breakdown

  • @cloudflare/workers-types is ^5. Wrangler 4.20+ has its runtime types as a peer dependency on the v5 line. Pinning v4 makes npm install fail with an ERESOLVE peer conflict; match the major that Wrangler expects.
  • lib: ["ES2022", "WebWorker"] gives TypeScript the fetch, crypto, and Response globals the Worker uses, without pulling in Node’s DOM-free lib set. Combined with types: ["@cloudflare/workers-types"], KVNamespace and Fetcher resolve.
  • noEmit: true: Wrangler bundles the Worker with esbuild, so TypeScript is used only as a type checker here. npx tsc --noEmit becomes a pure lint step.
  • module: ES2022 / moduleResolution: Bundler: matches how Wrangler and Vitest resolve ESM imports, so the .ts extension-less imports in worker.ts resolve the same way in the editor, the tests, and the build.

Step 10: Configure Wrangler and secrets

This is the file that makes the paywall work: run_worker_first tells Cloudflare to invoke the Worker ahead of the static assets for the premium and API paths, and to serve everything else straight from the asset cache.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/wrangler.jsonc
touch hugo-stripe-paywall-cloudflare-workers/.dev.vars.example

Add the code: hugo-stripe-paywall-cloudflare-workers/wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hugo-stripe-paywall",
  "main": "src/worker.ts",
  "compatibility_date": "2026-07-01",
  "assets": {
    "directory": "./public",
    "binding": "ASSETS",
    "not_found_handling": "404-page",
    // Run the Worker before serving assets for these paths so the paywall and
    // the Stripe endpoints get a chance to act. Everything else is served
    // straight from cache.
    "run_worker_first": ["/premium/*", "/api/*"]
  },
  "kv_namespaces": [
    {
      "binding": "SUBSCRIBERS",
      // Replace with the id printed by `wrangler kv namespace create SUBSCRIBERS`.
      "id": "REPLACE_WITH_YOUR_KV_NAMESPACE_ID"
    }
  ],
  "vars": {
    // Non-secret. Your Stripe recurring Price id (price_...).
    "STRIPE_PRICE_ID": "price_REPLACE_ME"
  },
  "observability": {
    "enabled": true
  }
}

Add the code: hugo-stripe-paywall-cloudflare-workers/.dev.vars.example

# Copy to .dev.vars (gitignored) for `wrangler dev`. Use Stripe TEST-mode keys.
STRIPE_SECRET_KEY="sk_test_your_key"
STRIPE_WEBHOOK_SECRET="whsec_your_webhook_signing_secret"
SESSION_SECRET="a-long-random-string-used-to-sign-cookies"

Detailed breakdown

  • run_worker_first: ["/premium/*", "/api/*"] is the core of the design. Static assets are served without invoking the Worker by default; these two patterns opt the premium and API paths into “Worker first” so the gate and the Stripe endpoints can act. Everything else — the home page, free posts, CSS — is served straight from Cloudflare’s cache with no Worker invocation, so it stays fast and cheap.
  • binding: "ASSETS" exposes the built site to the Worker as env.ASSETS.fetch(request), which is how allowed premium requests reach the real HTML file.
  • not_found_handling: "404-page" returns the nearest 404.html (the one Hugo built in Step 3) for unmatched paths.
  • kv_namespaces binds the subscriber store. The id is a placeholder until Step 12, where wrangler kv namespace create prints the real one.
  • Secrets are not in this file. STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and SESSION_SECRET are loaded from .dev.vars locally and from wrangler secret put in production. .dev.vars.example documents the shape without carrying real values; the real .dev.vars is gitignored.

Create your local secrets file (kept out of git):

cp .dev.vars.example .dev.vars
# then edit .dev.vars with your Stripe test key and any random SESSION_SECRET

Step 11: Write and run the tests

The cookie and Stripe-signature logic is the security-critical part, so it gets direct unit tests. They run in plain Node because the code only uses Web Crypto and fetch primitives.

Create the file

touch hugo-stripe-paywall-cloudflare-workers/vitest.config.ts
mkdir -p hugo-stripe-paywall-cloudflare-workers/test
touch hugo-stripe-paywall-cloudflare-workers/test/paywall.test.ts
touch hugo-stripe-paywall-cloudflare-workers/test/stripe.test.ts

Add the code: hugo-stripe-paywall-cloudflare-workers/vitest.config.ts

import { defineConfig } from "vitest/config";

// The paywall and Stripe helpers only use Web Crypto and fetch primitives, so
// they run in the default Node environment (Node 20+ provides globalThis.crypto
// and btoa/atob). No Workers pool needed for these unit tests.
export default defineConfig({
  test: {
    include: ["test/**/*.test.ts"],
  },
});

Add the code: hugo-stripe-paywall-cloudflare-workers/test/paywall.test.ts

import { describe, it, expect } from "vitest";
import {
  isPremiumPath,
  signSession,
  verifySession,
  readCookie,
  SESSION_COOKIE,
} from "../src/paywall";

const SECRET = "test-secret-please-change";
const NOW = 1_800_000_000; // fixed reference time in seconds

describe("isPremiumPath", () => {
  it("gates the premium section and its children", () => {
    expect(isPremiumPath("/premium")).toBe(true);
    expect(isPremiumPath("/premium/")).toBe(true);
    expect(isPremiumPath("/premium/scaling-playbook/")).toBe(true);
  });

  it("leaves free paths open", () => {
    expect(isPremiumPath("/")).toBe(false);
    expect(isPremiumPath("/posts/getting-started/")).toBe(false);
    expect(isPremiumPath("/premium-preview/")).toBe(false);
  });
});

describe("signSession / verifySession", () => {
  it("round-trips a valid session", async () => {
    const token = await signSession({ sub: "cus_123", exp: NOW + 3600 }, SECRET);
    const session = await verifySession(token, SECRET, NOW);
    expect(session).toEqual({ sub: "cus_123", exp: NOW + 3600 });
  });

  it("rejects a token signed with a different secret", async () => {
    const token = await signSession({ sub: "cus_123", exp: NOW + 3600 }, SECRET);
    expect(await verifySession(token, "wrong-secret", NOW)).toBeNull();
  });

  it("rejects a tampered payload", async () => {
    const token = await signSession({ sub: "cus_123", exp: NOW + 3600 }, SECRET);
    const [, sig] = token.split(".");
    const forged = `${btoa('{"sub":"cus_evil","exp":9999999999}')
      .replace(/=+$/, "")}.${sig}`;
    expect(await verifySession(forged, SECRET, NOW)).toBeNull();
  });

  it("rejects an expired session", async () => {
    const token = await signSession({ sub: "cus_123", exp: NOW - 1 }, SECRET);
    expect(await verifySession(token, SECRET, NOW)).toBeNull();
  });

  it("rejects a malformed token", async () => {
    expect(await verifySession("not-a-token", SECRET, NOW)).toBeNull();
  });
});

describe("readCookie", () => {
  it("extracts a named cookie", () => {
    const header = `theme=dark; ${SESSION_COOKIE}=abc.def; other=1`;
    expect(readCookie(header, SESSION_COOKIE)).toBe("abc.def");
  });

  it("returns null when absent or header missing", () => {
    expect(readCookie("theme=dark", SESSION_COOKIE)).toBeNull();
    expect(readCookie(null, SESSION_COOKIE)).toBeNull();
  });
});

Add the code: hugo-stripe-paywall-cloudflare-workers/test/stripe.test.ts

import { describe, it, expect } from "vitest";
import {
  hmacSha256Hex,
  verifyStripeSignature,
  checkoutSessionBody,
} from "../src/stripe";

const SECRET = "whsec_test";
const NOW = 1_800_000_000;

/** Build a valid `Stripe-Signature` header for a payload at time `t`. */
async function signHeader(payload: string, t: number): Promise<string> {
  const sig = await hmacSha256Hex(`${t}.${payload}`, SECRET);
  return `t=${t},v1=${sig}`;
}

describe("verifyStripeSignature", () => {
  const payload = '{"type":"invoice.paid","data":{"object":{"customer":"cus_1"}}}';

  it("accepts a correctly signed, recent payload", async () => {
    const header = await signHeader(payload, NOW);
    expect(await verifyStripeSignature(payload, header, SECRET, NOW)).toBe(true);
  });

  it("rejects a tampered payload", async () => {
    const header = await signHeader(payload, NOW);
    const altered = payload.replace("cus_1", "cus_evil");
    expect(await verifyStripeSignature(altered, header, SECRET, NOW)).toBe(false);
  });

  it("rejects a signature from the wrong secret", async () => {
    const badSig = await hmacSha256Hex(`${NOW}.${payload}`, "whsec_other");
    const header = `t=${NOW},v1=${badSig}`;
    expect(await verifyStripeSignature(payload, header, SECRET, NOW)).toBe(false);
  });

  it("rejects a timestamp outside the tolerance window", async () => {
    const header = await signHeader(payload, NOW - 10_000);
    expect(await verifyStripeSignature(payload, header, SECRET, NOW)).toBe(false);
  });

  it("rejects a missing or malformed header", async () => {
    expect(await verifyStripeSignature(payload, null, SECRET, NOW)).toBe(false);
    expect(await verifyStripeSignature(payload, "garbage", SECRET, NOW)).toBe(false);
  });
});

describe("checkoutSessionBody", () => {
  it("form-encodes a subscription checkout", () => {
    const body = checkoutSessionBody({
      priceId: "price_123",
      successUrl: "https://x.test/api/activate?session_id={CHECKOUT_SESSION_ID}",
      cancelUrl: "https://x.test/subscribe/",
    });
    const params = new URLSearchParams(body);
    expect(params.get("mode")).toBe("subscription");
    expect(params.get("line_items[0][price]")).toBe("price_123");
    expect(params.get("line_items[0][quantity]")).toBe("1");
    expect(params.get("success_url")).toContain("{CHECKOUT_SESSION_ID}");
  });
});

Run the tests:

npm test

Expected output:

 ✓ test/stripe.test.ts (6 tests) 3ms
 ✓ test/paywall.test.ts (9 tests) 4ms

 Test Files  2 passed (2)
      Tests  15 passed (15)

Detailed breakdown

  • NOW is a fixed constant, and both signSession and verifySession take the time as an argument, so the “expired” and “valid” cases are deterministic with no clock dependence.
  • The tamper test reuses the real signature but swaps the payload for a forged cus_evil body. Verification fails at the HMAC step, proving an attacker cannot rewrite sub without the secret.
  • signHeader in the Stripe test builds a genuine Stripe-Signature header with hmacSha256Hex, the same primitive the Worker verifies with. That makes the “accepts a valid signature” case a true round-trip rather than a mock.
  • The tolerance test signs a payload 10,000 seconds in the past, past the 300-second window, and asserts rejection — the replay guard.
  • These tests import from ../src/paywall and ../src/stripe directly. No Worker runtime, KV, or network is involved, so they run in milliseconds.

Step 12: Configure the Makefile

Create the file

touch hugo-stripe-paywall-cloudflare-workers/Makefile

Add the code: hugo-stripe-paywall-cloudflare-workers/Makefile

.DEFAULT_GOAL := help

.PHONY: help install site test check dev deploy clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-10s %s\n", $$1, $$2}'

install: ## Install Node dependencies
	npm install

site: ## Build the Hugo site into public/
	hugo --gc --minify

test: ## Run the Worker unit tests
	npm test

check: site ## Build the site, then type-check and bundle the Worker (no deploy)
	npx wrangler deploy --dry-run

dev: site ## Build the site and run the Worker locally
	npx wrangler dev

deploy: site ## Build the site and deploy the Worker to Cloudflare
	npx wrangler deploy

clean: ## Remove build artifacts
	rm -rf public/ dist/ .wrangler/ node_modules/

Confirm the default target prints the help screen:

make
  help       Show this help screen
  install    Install Node dependencies
  site       Build the Hugo site into public/
  test       Run the Worker unit tests
  check      Build the site, then type-check and bundle the Worker (no deploy)
  dev        Build the site and run the Worker locally
  deploy     Build the site and deploy the Worker to Cloudflare
  clean      Remove build artifacts

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the target list instead of running the first target. The help recipe greps the ## comments so the listing never drifts from the targets.
  • check and dev and deploy depend on site. Wrangler uploads whatever is in public/, so every target that touches the Worker rebuilds the site first. This prevents shipping a stale premium page.
  • check runs wrangler deploy --dry-run: it bundles and type-binds the Worker and validates the config without uploading anything, which is the fast local gate before a real deploy.

Step 13: Run and validate locally

wrangler dev serves the built site and the Worker together, using a local KV store and the secrets from .dev.vars. Build the site first, then start it.

make site
npx wrangler dev

In another terminal, confirm the free pages serve and the premium pages redirect when no cookie is present:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8787/
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8787/posts/getting-started/
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://localhost:8787/premium/scaling-playbook/
200
200
303 http://localhost:8787/subscribe/

To prove the positive path without running a real Stripe payment, seed the local KV store and mint a cookie with the same secret your .dev.vars uses:

# Mark a test customer active in the local KV store
npx wrangler kv key put --local --binding SUBSCRIBERS "sub:cus_test" active

# Mint a cookie signed with the SESSION_SECRET from .dev.vars
COOKIE=$(node -e '
const c = require("crypto");
const secret = "local-dev-session-secret-not-for-production"; // match .dev.vars
const b64u = b => Buffer.from(b).toString("base64")
  .replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
const payload = b64u(JSON.stringify({ sub: "cus_test", exp: Math.floor(Date.now()/1000)+3600 }));
const sig = c.createHmac("sha256", secret).update(payload).digest();
process.stdout.write(payload + "." + b64u(sig));
')

curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Cookie: paywall_session=$COOKIE" \
  http://localhost:8787/premium/scaling-playbook/
200

The same request with a cookie signed by any other secret returns 303 back to /subscribe/, because verifySession rejects the HMAC. That is the entire gate: a valid signature plus an active KV record.

Before a real deploy, run the dry-run check:

npx wrangler deploy --dry-run

The output lists the bindings the Worker will have and confirms the assets directory was read:

Your Worker has access to the following bindings:
Binding                                              Resource
env.SUBSCRIBERS (REPLACE_WITH_YOUR_KV_NAMESPACE_ID)  KV Namespace
env.ASSETS                                           Assets
env.STRIPE_PRICE_ID ("price_REPLACE_ME")             Environment Variable

Step 14: Deploy to Cloudflare and wire up Stripe

Create the real KV namespace and copy its id into wrangler.jsonc (replacing the REPLACE_WITH_YOUR_KV_NAMESPACE_ID placeholder):

npx wrangler kv namespace create SUBSCRIBERS

Set STRIPE_PRICE_ID in wrangler.jsonc to your real recurring Price id, then push the three secrets to Cloudflare (these are prod values, entered interactively, never committed):

npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
npx wrangler secret put SESSION_SECRET

Build and deploy:

make deploy

Finally, create a Stripe webhook (Dashboard → Developers → Webhooks) pointing at https://<your-worker-domain>/api/stripe/webhook, subscribed to customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.paid. Copy the webhook’s signing secret (whsec_...) into the STRIPE_WEBHOOK_SECRET you set above. When a subscriber cancels, Stripe posts customer.subscription.deleted, the Worker flips their KV record to inactive, and their next premium request redirects to /subscribe/ even though the cookie is still unexpired.

Detailed breakdown

  • Order matters: create the KV namespace and set secrets before make deploy, or the first request will fail on a missing binding.
  • The webhook secret is generated by Stripe when you create the endpoint. Set the endpoint first, read its signing secret, then run wrangler secret put STRIPE_WEBHOOK_SECRET.
  • SESSION_SECRET should be long and random. It is the only thing standing between a visitor and a forged cookie. Rotating it invalidates every existing cookie, which forces subscribers to reactivate but does not touch their Stripe subscription.

Troubleshooting

  • npm install fails with ERESOLVE on @cloudflare/workers-types. Wrangler’s peer dependency moved to the v5 line. Use "@cloudflare/workers-types": "^5.0.0" to match the Wrangler major you installed.
  • Premium pages return 200 even without a cookie. run_worker_first is missing or malformed. Confirm wrangler.jsonc lists "/premium/*" and that your Wrangler is 4.20 or newer; older versions ignore route patterns.
  • wrangler dev errors on a missing secret. Copy .dev.vars.example to .dev.vars and fill in the three values. wrangler dev reads .dev.vars automatically.
  • Activation always redirects to /subscribe/. The Worker requires the Checkout Session to be complete and paid. In Stripe test mode, complete the payment with card 4242 4242 4242 4242 so the session reaches that state.
  • A cancelled subscriber still sees premium pages. Confirm the webhook is subscribed to customer.subscription.deleted and that its signing secret matches STRIPE_WEBHOOK_SECRET. A signature mismatch returns 400 and KV is never updated.
  • Deprecation warnings from Hugo. Use locale/label under [languages.en] instead of top-level languageCode/languageName, and .Site.Language.Lang instead of .Site.LanguageCode.

Recap

You built a Hugo site whose premium folder is gated at the edge:

  • Hugo renders free and premium posts to static HTML under public/.
  • One Cloudflare Worker serves the whole site through the assets binding and, via run_worker_first, runs ahead of the assets only for /premium/* and /api/*.
  • Access is an HMAC-signed cookie (cheap, checked on every request) plus a KV record (authoritative, flips on cancellation).
  • Stripe Checkout in subscription mode drives payment; a server-verified activation endpoint mints the cookie; a signature-verified webhook keeps KV in sync.
  • The security-critical cookie and signature logic has unit tests that run in plain Node.

Next improvements

  • Add a /api/portal endpoint that opens the Stripe Billing Portal so subscribers can manage or cancel their own plan.
  • Store the subscription’s current-period-end in KV and surface a renewal date on an account page.
  • Add a short grace period on past_due invoices before flipping to inactive.
  • Move the subscriber lookup to a signed cookie with a shorter TTL plus a background refresh, trading the per-request KV read for occasional revalidation if read volume grows.