SaaS Architecture

Architecting a Secure Paywall: Next.js, Supabase Edge Functions, and Stripe Webhook Vulnerabilities

Architecting a Secure Paywall: Next.js, Supabase Edge Functions, and Stripe Webhook Vulnerabilities

Building a subscription paywall for a modern SaaS application requires seamless coordination between frontend user interfaces, backend edge infrastructure, and external payment processors. While setting up a Stripe Checkout button takes only a few minutes, correctly handling the full subscription lifecycle and securing subscription state synchronization against malicious exploits demands careful engineering.

In this guide, we break down how to architect a secure paywall using Next.js, Supabase Edge Functions, and Stripe, outline the critical webhook events you must handle, and detail how improper webhook implementations leave SaaS applications open to serious financial exploitation.


1. System Architecture Overview

A resilient paywall architecture consists of four distinct layers:

LayerComponentResponsibility
Frontend GuardNext.js (App Router / RSC)Renders UI, enforces server-side route access control based on DB state, and initiates Stripe Checkout.
State StorageSupabase (PostgreSQL)Stores tenant profile metadata, stripe_customer_id, and subscription_status protected by Row-Level Security (RLS).
Event ReceiverSupabase Edge FunctionReceives asynchronous HTTPS POST webhooks from Stripe, verifies cryptographical signatures, and updates database records via Service Role access.
Payment GatewayStripe APIHandles payment collection, recurring card billing, dunning cycles, and customer portal self-service.

2. Enforcing the Paywall in Next.js

In Next.js, access to premium features should be guarded on the server side using React Server Components (RSC) or Proxy Middleware.

Instead of querying Stripe API on every page request—which introduces severe latency and rate limits—Next.js checks the user's localized subscription state inside the Supabase database:

// app/dashboard/premium-feature/page.tsx
import { createClient } from '@/lib/supabase/server';

export default async function PremiumPage() { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser();

if (!user) redirect('/login');

const { data: subscription } = await supabase .from('subscriptions') .select('status, current_period_end') .eq('user_id', user.id) .single();

const isActive = subscription?.status === 'active' || subscription?.status === 'trialing';

if (!isActive) { redirect('/pricing?reason=paywall'); }

return <PremiumDashboardContent />; } ```


3. Real-Time Sync with Supabase Edge Functions

When a customer pays on Stripe, Stripe asynchronously dispatches a webhook HTTP POST request to your endpoint. Utilizing Supabase Edge Functions (built on Deno) provides low-latency execution and direct, secure integration with PostgreSQL.

Verifying Webhook Signatures

Never trust unverified incoming HTTP requests. The Edge Function must construct the event using Stripe raw request body and the `stripe-signature` header:

// supabase/functions/stripe-webhook/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import Stripe from "https://esm.sh/stripe@13.6.0?target=deno";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2023-10-16", httpClient: Stripe.createSubtleCryptoProvider(), });

const cryptoProvider = Stripe.createSubtleCryptoProvider();

serve(async (req) => { const signature = req.headers.get("stripe-signature"); const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;

if (!signature) { return new Response("Missing signature header", { status: 400 }); }

let event: Stripe.Event;

try { const body = await req.text(); event = await stripe.webhooks.constructEventAsync( body, signature, webhookSecret, undefined, cryptoProvider ); } catch (err) { console.error(`Webhook Signature Verification Failed: ${err.message}`); return new Response(`Webhook Error: ${err.message}`, { status: 400 }); }

// Initialize Supabase Admin Client (Bypassing RLS for system updates) const supabaseAdmin = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! );

switch (event.type) { case "checkout.session.completed": await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, supabaseAdmin); break; case "customer.subscription.updated": case "customer.subscription.deleted": await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, supabaseAdmin); break; case "invoice.payment_failed": await handlePaymentFailed(event.data.object as Stripe.Invoice, supabaseAdmin); break; }

return new Response(JSON.stringify({ received: true }), { headers: { "Content-Type": "application/json" }, status: 200, }); }); ```


4. Critical Stripe Subscription Webhook Events

To maintain strict subscription state consistency, your SaaS system must handle these core Stripe webhook events:

  • `checkout.session.completed`: Triggered after initial successful payment. Maps `stripe_customer_id` and `stripe_subscription_id` to internal `user_id`.
  • `customer.subscription.created`: Fired when a subscription is officially initialized (including free trial periods).
  • `customer.subscription.updated`: Fired when a customer upgrades, downgrades, cancels at period end, or changes plan billing cycles.
  • `customer.subscription.deleted`: Fired when a subscription fully expires or is manually revoked. Requires immediate entitlement cancellation.
  • `invoice.payment_succeeded`: Fired for every recurring monthly/annual renewal. Extends `current_period_end` in your database.
  • `invoice.payment_failed`: Fired when a recurring charge fails (e.g. expired credit card). Marks status as `past_due` or `unpaid` to lock paid feature access or notify the user.

5. How Incomplete Webhook Implementations Vulnerabilize & Exploit SaaS Systems

If your subscription synchronization logic is incomplete or naive, attackers can bypass paywalls, gain permanent unpaid access, or degrade server integrity. Here are the primary security exploits:

Exploit 1: Forged Webhook Payloads (Missing Signature Check) - **The Vulnerability**: Omitting `stripe.webhooks.constructEvent()` and parsing `req.json()` directly. - **The Exploit**: An attacker inspects open-source webhook endpoints or guesses the URL, then uses curl or Postman to send a forged JSON payload: ```json { "type": "checkout.session.completed", "data": { "object": { "client_reference_id": "attacker_user_id", "payment_status": "paid" } } } ``` - **Impact**: The system elevates the attacker to a premium plan without a single cent entering Stripe.

Exploit 2: Client-Side Entitlement Granting (The Redirect Flaw) - **The Vulnerability**: Granting paid status inside Next.js when the user reaches the success URL (e.g., `/success?session_id=cs_test_123`). - **The Exploit**: Attackers navigate directly to `/success?session_id=fake_id` or trigger client JS API endpoints without ever paying. Furthermore, if a legitimate customer closes their browser tab before the redirect completes, they don't receive their purchase. - **Impact**: Unauthorized feature access and broken customer fulfillment. Entitlements must ONLY be granted via server-verified webhooks.

Exploit 3: Race Conditions and Out-of-Order Delivery - **The Vulnerability**: Webhook deliveries over HTTP are asynchronous and not guaranteed to arrive in sequence. Event #2 (`customer.subscription.deleted`) can land before Event #1 (`customer.subscription.updated`). - **The Exploit**: If an event handler blindly updates records without checking payload timestamps (`event.created`), an outdated active payload arriving late will overwrite a canceled status. - **Impact**: The canceled user retains lifetime free access to paid SaaS features.

Exploit 4: Failure to Revoke Access on `invoice.payment_failed` - **The Vulnerability**: Only handling `checkout.session.completed` and ignoring recurring invoice events. - **The Exploit**: A user subscribes for 1 month, then replaces their card with an empty prepaid or canceled card. Next month, Stripe attempts renewal and emits `invoice.payment_failed`. If your system ignores this event, the database record stays `status = active`. - **Impact**: Customers consume expensive compute/AI resources for months without paying.

Exploit 5: Non-Idempotent Event Processing (Replay Attacks) - **The Vulnerability**: Incrementing usage credits (e.g., +1000 AI tokens) every time a webhook triggers, without logging the processed `event.id`. - **The Exploit**: Stripe automatically retries webhooks if your endpoint returns a 500 error or times out. Attackers intentionally trigger timeouts or re-transmit legitimate webhook payloads. - **Impact**: Repeated addition of usage quotas or duplicate billing ledger entries.


6. Summary Checklist for Production Security

  1. Verify Webhook Signatures: Always use raw request bodies and cryptographically validate `stripe-signature` secrets.
  2. Handle State Server-Side: Never elevate user permissions from Next.js client callbacks.
  3. Check Event Timestamps: Ensure database updates compare `event.created` against current DB timestamps to reject out-of-order updates.
  4. Implement Idempotency: Log processed `event.id` values in a `stripe_processed_events` PostgreSQL table.
  5. Handle Full Lifecycle: Handle `invoice.payment_failed` and `customer.subscription.deleted` to immediately suspend expired accounts.

Technologies covered in this article:

Next.jsStripeSupabaseEdge FunctionsTypeScriptPostgreSQL

Frequently Asked Questions

Why use Supabase Edge Functions instead of Next.js API routes for Stripe webhooks?

Supabase Edge Functions run globally on Deno at the edge with zero cold starts, providing low-latency execution, native connection pooling to PostgreSQL, and direct access to Supabase Service Role administrative clients.

What happens if a Stripe webhook signature validation fails?

If signature verification fails (e.g. invalid signature header), the Edge Function must immediately return a 400 Bad Request. This blocks attackers from sending forged payment payloads to elevate account status without paying.

How do you prevent race conditions when Stripe webhooks arrive out-of-order?

Store the event created timestamp (event.created) or subscription modified timestamp in PostgreSQL. When processing an incoming webhook, compare the payload timestamp against the existing database record to discard stale status updates.