This document describes the two-stage design of the membership system: the demo implementation that ships in this repository, and the production upgrade path that would replace it in a real deployment.
The core problem is the same in both stages: how do you cryptographically prove, at the CDN edge, that a request comes from a user who has paid — without blocking every request with a round-trip to a database?
Browser
│
├─ POST /api/checkout (CartContext items → Stripe Checkout Session)
│ └─ returns { url } → client redirects to stripe.com hosted page
│
├─ stripe.com (hosted checkout) ──── card data never crosses our server
│
├─ GET /checkout/success?session_id=cs_...
│ │ [Server Component — runs on Node.js, not Edge]
│ ├─ stripe.checkout.sessions.retrieve(sessionId) ← server-side only
│ ├─ guard: payment_status === 'paid'
│ └─ Set-Cookie: member_token=stripe:<session_id>; HttpOnly; Secure
│
└─ GET /article/*
│
▼ (Vercel Edge — before any page renders)
middleware.ts
├─ no member_token cookie ──▶ 302 /join?redirectBack=...
└─ cookie present ──▶ forward via x-member-token header
Server Component reads it directly
Key files:
| File | Role |
|---|---|
app/api/checkout/route.ts |
Creates Stripe Checkout Session, returns hosted URL |
app/checkout/success/page.tsx |
Verifies paid session; mints member_token cookie |
middleware.ts |
Edge gate — O(1) cookie presence check |
app/api/auth/login/route.ts |
Demo-only password login (returns 404 in production) |
app/api/webhooks/stripe/route.ts |
Receives Stripe events; fulfillment TODOs live here |
The redirect success page (/checkout/success) only runs if the user's browser reaches it. If the tab is closed during the Stripe redirect, payment is captured by Stripe but the cookie is never set — the user paid and has no access.
The webhook (POST /api/webhooks/stripe) fires regardless of browser state. In this demo it logs the event; in production it becomes the source of truth.
| Dimension | Demo (Stage 1) | Production (Stage 2) |
|---|---|---|
| Trust model | Cookie presence — easy to forge | JWT — cryptographically signed |
| Source of truth | Browser redirect completing | Stripe webhook writing to DB |
| Tab-close resilience | ✗ Payment without access | ✓ Webhook fires regardless |
| Expiry enforcement | 1-year cookie age (maxAge) |
Checked at Edge on every request |
| Revocation | Requires cookie deletion | Flip membership_active in WP meta |
| WP as Identity Provider | Not used | User meta is the authoritative state |
Stripe
│
├─ [webhook] POST /api/webhooks/stripe
│ │ event: checkout.session.completed
│ │ verified via stripe.webhooks.constructEvent() ← already implemented
│ │
│ ├─ Look up WP user by session.customer_details.email
│ │ GET /wp-json/wp/v2/users?search=<email> (admin Bearer token, server-side)
│ │
│ ├─ Write WP User Meta
│ │ POST /wp-json/wp/v2/users/<id>
│ │ body: { meta: { membership_active: true, membership_expires: <ISO date> } }
│ │
│ └─ 200 OK (Stripe retries on non-2xx — idempotency matters here)
│
Browser
│
├─ POST /api/auth/login { username, password }
│ │ validates credentials against WP Application Password
│ │
│ ├─ Fetch user meta to verify membership_active === true
│ │
│ ├─ Mint JWT
│ │ payload: { sub: userId, email, membershipActive: true, exp: <unix ts> }
│ │ signed with HS256 / RS256 using JWT_SECRET
│ │
│ └─ Set-Cookie: member_token=<jwt>; HttpOnly; Secure; SameSite=Lax
│
└─ GET /article/*
│
▼ (Vercel Edge)
middleware.ts
├─ no cookie ──▶ 302 /join
└─ cookie present
├─ jose.jwtVerify(token, secret) ← ~0.1ms, no network
├─ check payload.membershipActive ← embedded in token
├─ check payload.exp < Date.now() ← expiry at Edge
└─ allowed ──▶ forward decoded claims via request header
Server Component receives structured data
without re-parsing the JWT
WordPress ships with a user management system, roles, and a REST API that exposes arbitrary user meta. Treating WP user meta as the entitlement store gives you:
- Single admin UI — membership status visible and editable in WP Admin
- Revocation without code — flip
membership_active: falsein WP to instantly revoke access on the next middleware check - Audit trail — WP stores meta change timestamps; every grant is traceable to a Stripe session ID
The webhook handler writes membership_expires alongside membership_active. The JWT embeds an exp claim matching this date. Middleware rejects expired tokens without any network call — the expiry is baked into the signed token.
In the production build of this repo, Stage 2's WP-as-IdP model was adapted: rather than creating WP user accounts for paying customers and storing entitlement in user meta, paying customers are stored as Member custom post type records.
| Concern | WP Users approach | Member CPT approach (implemented) |
|---|---|---|
| Auth mechanism | WP credentials + Application Password | Stripe cookie — no WP login |
| Role collision risk | set_role() overwrites existing roles |
No roles involved |
| Admin surface | Customer accounts appear in Users list | Customers appear in Members CPT |
| Revocation | Delete/deactivate WP user | Delete Member post |
| Data ownership | Coupled to WP auth system | Fully independent |
The core insight: customers in this system never authenticate via WordPress. They authenticate via a Stripe-issued cookie. Creating a WP user account implies a WP login workflow that does not exist — it adds risk (role collisions, password reset emails, Users list pollution) with no benefit.
Each paying customer creates one member CPT post:
- Post title: customer email (for admin visibility)
member_emailmeta: email addressstripe_session_idmeta: Stripe Checkout Session ID for audit/refund lookupmembership_granted_atmeta: UTC timestamp of fulfillment
Visible in WP Admin → Members. The Users list remains reserved for content authors and site admins.
The cookie-based auth model (member_token=stripe:<session_id>) and the JWT upgrade described in Stage 2 above are orthogonal to whether customers are WP users or CPT posts. The JWT upgrade replaces the cookie value with a signed token — the membership store backing it can be either WP user meta or CPT post meta. The CPT approach actually simplifies the JWT upgrade: instead of looking up WP user meta, the /api/auth/login route would query the member CPT by email to verify an active membership before minting the token.
The middleware currently does:
// Stage 1 (demo) — cookie presence only
const token = request.cookies.get('member_token')?.value;
if (!token) redirect('/join');The Stage 2 upgrade is a drop-in replacement:
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
try {
const { payload } = await jwtVerify(token, secret);
if (!payload.membershipActive) return NextResponse.redirect('/join');
// Inject claims for Server Components — no second parse needed
const res = NextResponse.next();
res.headers.set('x-membership-expires', String(payload.exp));
return res;
} catch {
// Expired or tampered token
return NextResponse.redirect('/join');
}jose is an Edge-compatible JWT library (no Node.js crypto module dependency). The jwtVerify call runs in ~0.1 ms — well within Vercel Edge's CPU budget.
The cookie value changes from stripe:<session_id> (auditable but not verifiable) to a signed JWT (verifiable without a DB call). The Set-Cookie mechanism is identical, so no browser-side changes are needed.
Covered in the login route comments. The production flow extends this:
- On login, fetch a WordPress nonce server-side:
GET /wp-json/wp/v2/users/me - Set a second httpOnly cookie:
woo_nonce=<nonce>; SameSite=Lax - Every Route Handler that calls the WooCommerce Store API reads this cookie and forwards it as
X-WP-Nonce
The nonce never appears in browser JS. A cross-origin attacker cannot read an httpOnly cookie, so this also functions as CSRF mitigation for cart mutations.
- Not signed — any string that
middleware.tsfinds in themember_tokencookie grants access - An attacker who can set a cookie (e.g., via subdomain cookie injection) gains access
- Mitigated partially by
HttpOnly(no JS read) andSecure(HTTPS only), but the value itself carries no cryptographic proof
- HS256/RS256 signed — the server refuses any token it did not sign
- Expiry is enforced at the Edge without a DB call
- Membership claims (active, expiry date, user ID) are embedded and integrity-protected
- Revocation gap: a JWT issued for 1 year remains valid until expiry even if WP meta is flipped. Mitigation: use short-lived tokens (15 min) + a refresh token pattern, or maintain a small revocation list in KV storage (Vercel KV / Upstash)
Both stages qualify for SAQ-A — the lowest PCI DSS burden — because card data is handled exclusively on Stripe's hosted Checkout page. This is enforced by architecture: /api/checkout only creates a Checkout Session and returns a URL; it never handles card numbers.
Q: Why not verify the Stripe session on every request instead of using a cookie?
Stripe's API has rate limits (~100 req/s) and adds 200–400 ms latency. A CDN edge processes thousands of requests per second. Embedding the verification result in a signed JWT lets the Edge make the access decision in sub-millisecond time with zero external network calls — the same model used by every large-scale auth system (Google, GitHub, AWS IAM).
Q: Why not store membership state in a cookie directly rather than JWT?
An unsigned cookie is a promise the server made to itself with no way to verify it wasn't modified. A JWT is a verifiable claim — the signature proves the server issued it and the payload hasn't been altered. For anything that unlocks paid content, cryptographic proof is not optional.
Q: What would you use for token revocation?
Short-lived access tokens (15 min) backed by a long-lived refresh token stored in the DB (WP user meta or a Vercel KV store). On refresh, check membership_active in real time. This limits the revocation window to 15 minutes without adding latency to the hot path. At scale, a Redis-backed token blocklist handles immediate revocation when you need it.
Q: Why is the webhook the source of truth rather than the success page redirect?
The redirect only fires if the user's browser completes the round-trip. The webhook fires from Stripe's infrastructure regardless of browser state — tab closed, network dropped, ad blocker, anything. Treating a browser-initiated event as the entitlement source introduces a class of fulfillment failures (paid but no access) that are invisible in logs and require manual support resolution. The webhook eliminates this category entirely.