Paywall Worker: Python on Cloudflare

Nov 4, 2026 · Tutorial, Reference, and Repo description

Overview

A Python Worker sits in front of a static single-page app and decides, for every request, whether to serve the file, count a free page load, or show a paywall. The operator can switch the wall off, make it login-only, change prices, plans, currencies, free paths, comped email domains and enabled providers at any time from /_pw/admin, with no redeploy; changes reach every edge location within about 10 seconds.

Every request passes the gate before any SPA byte is served Python Worker, run_worker_first: true Visitor's browserone HttpOnly cookie gate.decide()allow, meter or wall for each pathfrom live config and the session /_pw/ routeswall, sign-in, checkout, adminwebhooks; fulfill() grants once Static assetsyour SPA build (ASSETS) KVsessions, OAuth state D1users, payments,entitlements, config Sign-in providersGoogle, Facebook, GitHub,Microsoft, Discord, GitLab, OIDC Payment providersStripe (EU cards, Wero), PayPal,Razorpay UPI, Mercado Pago Pix Operator/_pw/admin or API configOAuthcheckout, webhooks

The SPA's HTML, JavaScript and CSS are served only after gate.decide() allows them; payment confirmations arrive as webhooks or returns and are applied once by fulfill().

The code is about 1,900 lines of standard-library Python (no third-party packages, so nothing to vendor and nothing to slow cold starts). The core is runtime-neutral, so all 15 tests run under plain CPython against fake KV, D1 and HTTP. Every file of the tested repository is on the companion Paywall Worker Source page.

Payment and sign-in coverage

Market Method offered Integration Why this one
Global PayPal PayPal Orders v2 Requested; also the single most-used payment brand in Europe
European Union Cards plus Apple Pay and Google Pay, with Wero / iDEAL, Bancontact, SEPA Debit and Klarna Stripe Checkout In Worldpay's 2026 report, European e-commerce splits 38% wallets, 19% credit cards, 17% debit, 14% pay-by-bank, 9% BNPL (Frisbii summary). PayPal leads the wallets and is covered above, so the largest remaining block is cards and card-based wallets. One Stripe integration also picks up Wero, which replaces iDEAL in the Netherlands from Q4 2026 (Stripe).
India UPI Razorpay Payment Links UPI was 85.5% of India's digital payment volume in H2 2025 per the RBI (report summary)
Brazil Pix Mercado Pago Payments API Pix carried 42% of Brazilian e-commerce value in 2025, ahead of credit cards at 40% (Poder360, citing Worldpay)
Anywhere Voucher codes Built in Gifts, press passes, scholarships, offline sales

Sign-in: Google, Facebook and GitHub as requested, plus Microsoft, Discord, GitLab (including self-hosted), any OpenID Connect issuer (Auth0, Okta, Keycloak, Zitadel) and passwordless email links.

Design choices

Part 1: Tutorial

This walks through building the Worker in the order the pieces depend on each other. Each step shows the code that matters; the source page has every file in full.

Step 1: Scaffold a Python Worker

Install uv and Node.js, then create the project with Cloudflare's pywrangler CLI (docs):

uvx --from workers-py pywrangler init paywall
cd paywall
mkdir -p src/paywall/auth src/paywall/pay tests spa/dist

Put your SPA's build output in spa/dist (for Vite, npm run build then copy or point assets.directory at its dist).

Step 2: Route every request through the Worker

The key setting is run_worker_first: true. Without it, static assets are served before Python runs and only unmatched paths would be gated (static assets docs). not_found_handling keeps client-side routes working by falling back to index.html.

{
  "name": "paywall",
  "main": "src/entry.py",
  "compatibility_date": "2026-11-04",
  "compatibility_flags": ["python_workers"],
  "assets": {
    "directory": "./spa/dist",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": true
  },
  "d1_databases": [{ "binding": "DB", "database_name": "paywall", "database_id": "..." }],
  "kv_namespaces": [{ "binding": "KV", "id": "..." }]
}

Step 3: Keep the runtime at the edge of the program

src/entry.py is the only file that imports from workers or js. It turns the runtime's request into a plain dataclass, hands it to App, and turns the answer back into a response. When the answer is ServeAsset, it fetches the file from the ASSETS binding and adds headers such as Cache-Control: private.

from workers import Response, WorkerEntrypoint
from paywall.app import App
from paywall.web import Req, ServeAsset

async def to_req(request) -> Req:
    js = getattr(request, "js_object", request)
    headers = {str(k).lower(): str(v) for k, v in js.headers.entries()}
    body = b""
    if js.method not in ("GET", "HEAD"):
        body = (await js.clone().arrayBuffer()).to_bytes()
    return Req(str(js.method), str(js.url), headers, body)

class Default(WorkerEntrypoint):
    async def fetch(self, request):
        result = await App(self.env).handle(await to_req(request))
        if isinstance(result, ServeAsset):
            js_request = getattr(request, "js_object", request)
            asset = await self.env.ASSETS.fetch(js_request)
            if not result.headers:
                return asset
            from js import Response as JsResponse
            out = JsResponse.new(asset.body, asset)
            for name, value in result.headers.items():
                out.headers.set(name, value)
            return out
        return Response(result.body, status=result.status, headers=result.headers)

The payoff is testability: everything below entry.py runs under CPython with fakes, so you can test OAuth and webhooks without a network or a Workers runtime.

Step 4: Write the access decision as a pure function

gate.decide() is the paywall policy in 20 lines. It takes the config and what we know about the visitor, and returns allow, meter (serve and count one free page load) or wall.

def decide(cfg, path, *, user, entitled, meter_used, is_document):
    if cfg["mode"] == "off" or is_public(cfg, path):
        return ALLOW
    if user and (cfg["mode"] == "login" or entitled or is_comped(cfg, user)):
        return ALLOW
    limit = cfg["metered_free_loads"]
    if limit:
        if is_document and meter_used < limit:
            return METER
        if not is_document and 0 < meter_used <= limit:
            return ALLOW  # let a metered page's JS and CSS finish loading
    return WALL

If your business model differs (meter per article, free weekends, a student tier by country), this is the function to change; its tests in tests/test_paywall.py show the expected shape.

Step 5: Sessions that cost one KV read

The browser holds one opaque __Host-pw cookie (HttpOnly, Secure, SameSite=Lax). KV stores the session under the SHA-256 of the token, with the user's id, verified email and a cached entitlement expiry refreshed at most once a minute. Sign-in rotates the token to prevent session fixation. Anonymous visitors only get a session when they need one: to start an OAuth flow or to be metered.

Step 6: Make configuration data, not code

config.py defines defaults and a strict validate(). The live config is one JSON row in D1, cached per isolate for 10 seconds. An invalid update is rejected with a message; an invalid stored row falls back to defaults rather than taking the site down.

# Turn on metering: 3 free page loads, then the wall
curl -X PUT https://app.example/_pw/admin/config \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"metered_free_loads": 3}'

# Drop the paywall entirely, e.g. during an outage or a free week
curl -X PUT https://app.example/_pw/admin/config \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"mode": "off"}'

Admins listed in admin_emails (or the ADMIN_EMAILS secret, for bootstrapping) can do the same from the /_pw/admin page after signing in.

Step 7: Sign-in from a provider table

auth/oauth.py drives every OAuth provider from one table. Adding one is a dictionary entry:

"github": {
    "label": "GitHub",
    "authorize": "https://github.com/login/oauth/authorize",
    "token": "https://github.com/login/oauth/access_token",
    "userinfo": "https://api.github.com/user",
    "emails": "https://api.github.com/user/emails",
    "scope": "read:user user:email",
    "pkce": True,
},

The flow: /_pw/login/github stores a random state in KV bound to the browser's session hash (plus a PKCE verifier), then redirects. The callback checks that the state exists, matches the provider and was started by this same browser, exchanges the code, reads the profile, and maps it with normalize(). Accounts are linked across providers only when both sides have a verified email; Microsoft addresses are never treated as verified because tenant admins can set them freely.

Magic links (auth/magic.py) send a one-time, 15-minute token through any JSON email API (Resend by default). The link opens a confirm button that POSTs, so mail scanners that prefetch links cannot burn the token.

Step 8: One payment contract, five providers

Each module in pay/ exposes configured(), create(), on_return() and webhook(). Checkout writes a payments row first (our ref, plan, days, amount, currency), then sends ref to the provider as its reference field. Whatever confirms the payment later (webhook, browser return, or a poll) calls the same function:

async def fulfill(app, ref, provider, amount, currency, external_id=""):
    row = await app.store.payment_get(ref)
    if not row or row["provider"] != provider:
        return False
    if row["status"] == "paid":
        return True  # duplicate notification
    if int(amount) != int(row["amount"]) or currency.upper() != row["currency"]:
        await app.store.payment_transition(ref, "mismatch", external_id)
        return False
    if await app.store.payment_transition(ref, "paid", external_id):
        await app.store.grant(row["user_id"], int(row["days"]), f"{provider}:{external_id}")
    return True

payment_transition() is a single UPDATE ... WHERE status IN ('created', 'pending'); only the caller that changes a row grants access, so retries and races cannot double-grant.

What differs per provider is how payment is confirmed:

Step 9: Run it

uv run pytest                       # 15 tests, under a second
cp .dev.vars.example .dev.vars      # fill in sandbox keys
npx wrangler d1 execute paywall --local --file=schema.sql
uv run pywrangler dev               # http://localhost:8787

Visit /, get bounced to /_pw/wall, sign in, buy a pass with a sandbox card, and land back in the app. To receive Stripe webhooks locally, run stripe listen --forward-to localhost:8787/_pw/webhook/stripe and put the printed whsec_... in .dev.vars. For PayPal, Razorpay and Mercado Pago, expose the dev server with a tunnel (for example cloudflared tunnel --url http://localhost:8787) and register that URL in each sandbox dashboard.

Step 10: Let your SPA know who is reading

The SPA can call GET /_pw/me for {user, entitled, entitled_until, mode, wall} to show a name, an upgrade link, or a sign-out button. Any gated non-HTML request returns 401 (signed out) or 402 (signed in, unpaid) with the wall URL, so a client-side fetch wrapper can redirect on those codes.

Part 2: Reference

Routes

All paywall routes live under /_pw/ so they cannot collide with the SPA's own routes. State-changing requests must come from the site's own origin (checked via Origin or Sec-Fetch-Site), except webhooks and calls bearing the admin token.

Method Path Purpose Who
GET /_pw/wall?next=/path Paywall: sign-in buttons, plans, payment buttons, voucher form Anyone
GET /_pw/me JSON: user, entitled, entitled_until, mode Anyone
GET /_pw/login/{provider}?next= Start OAuth Anyone
GET /_pw/callback/{provider} OAuth redirect URI Provider
POST /_pw/magic Send an email sign-in link (email, next) Anyone
GET, POST /_pw/magic/verify?t= Confirm page, then sign-in Link holder
POST /_pw/logout End the session Signed in
POST /_pw/checkout/{provider} Start payment (plan, currency) Signed in
GET /_pw/return/{provider} Browser return from the provider Buyer
GET /_pw/status/{ref} Poll a payment: created, pending, paid, failed, mismatch The buyer
POST /_pw/webhook/{provider} Provider notifications Signed by provider
POST /_pw/redeem Redeem a voucher (code) Signed in
GET /_pw/admin Admin page Admin
GET, PUT /_pw/admin/config Read or update config (partial JSON is merged) Admin
POST /_pw/admin/grant Give a user access (email, days; 0 = lifetime) Admin
POST /_pw/admin/codes Mint vouchers (count up to 500, days, uses, expires_at) Admin
GET /_pw/admin/payments?limit=50 Recent payments Admin

Any other path is the SPA. Gated HTML requests redirect to the wall; gated non-HTML requests get 401 (signed out) or 402 (signed in, unpaid) with {"error": "payment_required", "wall": ...}.

Configuration keys

Key Type Default Meaning
mode off, login, paid paid No wall; sign-in only; sign-in plus a pass
public_paths list of globs /favicon.ico, /robots.txt, /public/* Always served, never walled
metered_free_loads integer 0 Free page loads per browser session before the wall; 0 disables
plans list 30-day pass, lifetime {id, name, days, prices}; days: 0 is lifetime; prices maps currency to integer minor units
default_currency ISO 4217 USD Used when the country is not mapped
country_currency object IN to INR, BR to BRL, GB to GBP, euro area to EUR Picks the buyer's currency from CF-IPCountry
payment_providers list all five Which payment buttons may appear (each also needs its secrets)
provider_currencies object Razorpay INR, Mercado Pago BRL, Stripe and PayPal eight majors A button appears only for currencies its provider accepts
auth_providers list google, facebook, github, magic Which sign-in options may appear (each also needs its secrets)
admin_emails list empty Verified emails that can open /_pw/admin; they also get free access
free_email_domains list empty Verified emails at these domains get free access (for example a university)
razorpay_upi_only boolean false Create UPI-only Razorpay links instead of the full hosted checkout
session_days integer 30 Session lifetime in KV and the cookie
site_name, wall_message string Text on the wall and in emails

Unknown keys, non-integer prices and duplicate plan ids are rejected with HTTP 400 and a message. A purchase records the plan's days at checkout time, so later price or plan edits never change what someone already paid for.

Secrets and variables

Set secrets with npx wrangler secret put NAME (production) or in .dev.vars (local). A provider appears on the wall only when its secrets are present and it is listed in config.

Name Needed for Notes
ADMIN_TOKEN Admin API from scripts Long random string; sent as Authorization: Bearer
ADMIN_EMAILS Bootstrapping the first admin Comma-separated; must match a verified sign-in email
PUBLIC_ORIGIN Optional Canonical https://host used to build redirect URIs
SESSION_COOKIE Optional Override the __Host-pw cookie name
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET Google sign-in
FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET Facebook sign-in App ID and App Secret; FACEBOOK_GRAPH_VERSION var, default v23.0
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET GitHub sign-in An OAuth App (not a GitHub App)
MICROSOFT_, DISCORD_, GITLAB_CLIENT_ID and _CLIENT_SECRET Those sign-ins GITLAB_BASE_URL for self-hosted GitLab
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_LABEL Generic OpenID Connect Endpoints come from the issuer's discovery document
EMAIL_API_KEY, EMAIL_FROM, EMAIL_API_URL Magic links Defaults target Resend; any API taking {from, to, subject, html} works
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET Stripe sk_test_..., whsec_...
PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID, PAYPAL_ENV PayPal PAYPAL_ENV is sandbox or live
RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET Razorpay UPI
MERCADOPAGO_ACCESS_TOKEN, MERCADOPAGO_WEBHOOK_SECRET Mercado Pago Pix Secret from the Webhooks page of your application

Sign-in providers

Register the redirect URI https://YOUR_HOST/_pw/callback/{provider} in each console.

Provider Scopes PKCE Email used for account linking
Google openid email profile Yes When email_verified is true
Facebook email,public_profile No Yes; Graph returns only confirmed emails
GitHub read:user user:email Yes Primary email, when GitHub marks it verified
Microsoft openid email profile Yes Never (tenant admins can set arbitrary addresses)
Discord identify email No When Discord marks the account verified
GitLab openid email profile Yes When email_verified is true
Any OIDC issuer openid email profile Yes When email_verified is true
Email link none n/a Always; possession of the inbox is the proof

Payment providers

Provider Methods Webhook URL to register Events used How confirmation is verified
Stripe Cards, Apple Pay, Google Pay, Link, Wero / iDEAL, Bancontact, SEPA, Klarna (whatever the Dashboard enables) /_pw/webhook/stripe checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed HMAC-SHA256 of t.body, 300 s tolerance
PayPal PayPal balance, cards, Pay Later where offered /_pw/webhook/paypal PAYMENT.CAPTURE.COMPLETED Capture response on return; webhook via PayPal's verify-webhook-signature API
Razorpay UPI (plus cards, netbanking, wallets unless UPI-only) /_pw/webhook/razorpay payment_link.paid Callback HMAC over link id, reference, status and payment id joined by pipes; webhook HMAC of the raw body
Mercado Pago Pix Sent per payment as notification_url payment x-signature HMAC over id:..;request-id:..;ts:..;, then the payment is fetched from the API
Voucher codes n/a None n/a Atomic decrement of uses_left

Data model (D1)

Table Key Holds
config id = 1 The validated config JSON
users id Email, whether it is verified, display name
identities (provider, subject) Which provider accounts belong to which user
entitlements user_id Access expiry in Unix seconds; 253402300799 means lifetime; new passes stack on unexpired time
payments ref One row per checkout: provider ids, plan, days, amount, currency, status
codes code Vouchers with days, remaining uses, optional expiry

KV keys: sess:{sha256(token)} (sessions), oauth:{state} (10 minutes), magic:{sha256(token)} (15 minutes), magic-rate:{sha256(email)} (at most 5 links per hour).

Security properties

Python Workers gotchas this code works around

Part 3: README.md

paywall-worker

An optional, operator-adjustable paywall for any static single-page app, written in pure Python for Cloudflare Python Workers. Sign in with Google, Facebook, GitHub, Microsoft, Discord, GitLab, any OIDC issuer, or an email link. Pay with Stripe (cards, wallets, Wero / iDEAL, SEPA), PayPal, UPI via Razorpay, Pix via Mercado Pago, or a voucher code.

Repository layout

paywall-worker/
├── wrangler.jsonc          Worker, assets, D1 and KV bindings
├── pyproject.toml          no runtime dependencies; dev: workers-py, pytest
├── schema.sql              D1 tables
├── .dev.vars.example       every secret, with sandbox placeholders
├── spa/dist/               your built SPA (index.html, assets/...)
├── src/
│   ├── entry.py            Workers entry point (only file touching the runtime)
│   └── paywall/
│       ├── app.py          router, wall, checkout, admin
│       ├── gate.py         the access decision
│       ├── config.py       defaults and validation
│       ├── session.py      KV-backed sessions
│       ├── store.py        D1 and KV access
│       ├── web.py          request/response types
│       ├── crypto.py       HMAC, PKCE, tokens
│       ├── net.py          outbound fetch
│       ├── auth/oauth.py   OAuth and OIDC providers
│       ├── auth/magic.py   email links
│       └── pay/            stripe, paypal, razorpay, mercadopago, codes
└── tests/                  CPython tests with fake KV, D1 and HTTP

Prerequisites

Installation

git clone https://github.com/you/paywall-worker && cd paywall-worker
uv sync                      # installs pywrangler and pytest into .venv
uv run pytest                # 15 tests should pass

Build

The Worker has no build step: pywrangler uploads the Python sources as they are. Build your SPA so its output lands in spa/dist:

(cd ../my-spa && npm ci && npm run build) && rm -rf spa/dist && cp -r ../my-spa/dist spa/dist

If your SPA has public files that must load before sign-in (a logo on the wall, Open Graph images), put them under spa/dist/public/ or add their globs to public_paths.

Local development

cp .dev.vars.example .dev.vars           # fill in sandbox keys
npx wrangler d1 execute paywall --local --file=schema.sql
uv run pywrangler dev                    # http://localhost:8787

Browsers accept Secure cookies on http://localhost, so the __Host-pw cookie works in dev. Register http://localhost:8787/_pw/callback/{provider} as an extra redirect URI in each OAuth console while developing.

Deployment

  1. Create storage and paste the printed ids into wrangler.jsonc:
npx wrangler d1 create paywall
npx wrangler kv namespace create KV
npx wrangler d1 execute paywall --remote --file=schema.sql
  1. Add secrets (repeat for each provider you enable):
npx wrangler secret put ADMIN_TOKEN        # e.g. output of: openssl rand -hex 32
npx wrangler secret put ADMIN_EMAILS       # you@example.com
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
# ...facebook, github, paypal, razorpay, mercadopago, email as needed
  1. Deploy, then attach a custom domain in the dashboard (Workers & Pages, your Worker, Settings, Domains & Routes):
uv run pywrangler deploy
  1. Point each provider at the deployed host:
Provider Where Value
Google Cloud Console, APIs & Services, Credentials, OAuth client (Web) Redirect URI https://HOST/_pw/callback/google
Facebook Meta for Developers, your app, Facebook Login, Settings Valid OAuth Redirect URI https://HOST/_pw/callback/facebook
GitHub Settings, Developer settings, OAuth Apps Callback URL https://HOST/_pw/callback/github
Stripe Developers, Webhooks https://HOST/_pw/webhook/stripe; enable your payment methods under Settings, Payment methods
PayPal Developer Dashboard, your app, Webhooks https://HOST/_pw/webhook/paypal; copy its id to PAYPAL_WEBHOOK_ID
Razorpay Dashboard, Accounts & Settings, Webhooks https://HOST/_pw/webhook/razorpay, event payment_link.paid
Mercado Pago Your integrations, your application, Webhooks Copy the signing secret to MERCADOPAGO_WEBHOOK_SECRET; the URL is sent per payment
  1. Sign in at https://HOST/_pw/wall with an ADMIN_EMAILS address, open /_pw/admin, and set prices, plans and providers. Flip PAYPAL_ENV to live and swap test keys for live ones when ready.

Operating

Troubleshooting

Symptom Likely cause
A sign-in button is missing Provider not in auth_providers, or its client id or secret is unset
A payment button is missing Provider not configured, or the shown currency is not in its provider_currencies
"Sign-in was started in another browser" Cookies blocked, or the callback host differs from the login host; set PUBLIC_ORIGIN
Paid but still walled Webhook not reaching the Worker; check the provider's delivery log and the webhook secret
Webhook returns 400 Wrong signing secret, or clock skew over 5 minutes (Stripe) or 10 minutes (Mercado Pago)
Config change not visible Up to 10 seconds of per-isolate caching; reload

License

MIT. Provider names are trademarks of their owners; this project is not affiliated with any of them.

Sources