Skip to main content
Aatvi AI
Aatvi AISoftware engineering
← Back to Insights
·9 min read

Synthetic Teardown Lab: Multi-Tenant SaaS Code Rescue

A synthetic engineering teardown of an AI-generated B2B SaaS application: test fixtures, cross-tenant leaks, webhook idempotency, and pool exhaustion.

AI Code RescueAudit ReportSaaS ArchitectureVibe CodingProduction Readiness
Synthetic teardown phases: tenant boundary validation, webhook idempotency transactions, and connection pool stabilization.
Remediating a synthetic SaaS fixture stabilizes cross-tenant authorization, payment idempotency, and serverless database pools.

This synthetic engineering teardown examines a representative multi-tenant B2B SaaS application generated with AI coding assistants. Built on Next.js, Supabase, and Stripe, the synthetic application functions in single-user demos but fails under concurrent multi-tenant workloads. This teardown documents four critical failure modes discovered in the test fixture: cross-tenant authorization leaks where client-supplied route parameters accessed unauthenticated tenant records, payment webhook race conditions causing duplicate fulfillment, database connection pool exhaustion under serverless concurrency, and unvalidated request payloads.

Notice: This is an illustrative synthetic engineering teardown based on reproducible test fixtures, not an audit of a real customer system. It demonstrates the technical rigor and verification depth applied during an engineering stabilization engagement.

The short version

Our synthetic SaaS test fixture modeled a B2B subscription platform generated via AI prompts. While passing automated happy-path UI tests, deep inspection revealed four critical blockers:

  1. Cross-tenant authorization leak: Route Handlers trusted client-supplied workspace IDs in dynamic route parameters while querying via privileged clients, bypassing database Row-Level Security.
  2. Non-idempotent payment webhooks: Stripe webhook handlers lacked transactional idempotency gates, permitting concurrent retry requests to double-credit user subscriptions.
  3. Database connection exhaustion: Direct PostgreSQL client drivers in serverless API routes exhausted database connection pools under modest concurrent traffic.
  4. Unvalidated request payloads: API handlers cast raw request payloads using TypeScript as, permitting unvalidated fields to trigger unhandled runtime database exceptions.

Compare this engineering teardown with our sample AI code rescue audit report template and our guide on how to fix broken vibe-coded apps.

1. Teardown 1: Cross-Tenant Data Access in Route Handlers

The synthetic application featured a dashboard displaying workspace invoices. The frontend fetched data from an API route: /api/workspaces/[id]/invoices.

The Defect in the AI-Generated Code

The AI assistant wrote an API Route Handler that authenticated the user but trusted the client-supplied workspace ID parameter:

// VULNERABLE: Service-role client bypasses RLS and trusts the client route parameter
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: workspaceId } = await params;
  // FLAW: Using service_role key directly in API routes bypasses all RLS rules
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  
  const { data: invoices } = await supabase
    .from("invoices")
    .select("*")
    .eq("workspace_id", workspaceId);

  return NextResponse.json({ invoices });
}

Because the Route Handler did not verify whether the authenticated user.id had permission to access workspaceId, an attacker could view any organization's invoices simply by changing the ID in the URL.

The Remediated Pattern

We remediated this endpoint by validating workspace membership within the database query or extracting tenant claims directly from server-verified JWT claims:

// HARDENED: Explicit tenant membership verification
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id: workspaceId } = await params;
  const supabase = createServerClient(/* ... */);
  
  const { data: { user }, error: authError } = await supabase.auth.getUser();
  if (authError || !user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Verify caller belongs to the target workspace
  const { data: member } = await supabase
    .from("workspace_members")
    .select("role")
    .eq("workspace_id", workspaceId)
    .eq("user_id", user.id)
    .single();

  if (!member) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const { data: invoices } = await supabase
    .from("invoices")
    .select("id, amount, status, created_at")
    .eq("workspace_id", workspaceId);

  return NextResponse.json({ invoices });
}

Cross-reference this pattern with our guide on Bolt, v0, and Replit auth and RLS mistakes.

2. Teardown 2: Stripe Webhook Double Fulfillment

The synthetic application accepted recurring subscription payments via Stripe. During load testing, simulating Stripe webhook network retries revealed a critical concurrency vulnerability.

The Defect

The AI assistant wrote a webhook handler that acknowledged the event before fulfilling the subscription or fulfilled the order before recording the idempotency key:

// VULNERABLE: Concurrent webhook retries both pass the check
export async function POST(req: NextRequest) {
  const payload = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(payload, sig, endpointSecret);

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    // RACE CONDITION: Two concurrent deliveries both find status = 'pending'
    const { data: order } = await db
      .from("orders")
      .select("status")
      .eq("stripe_session_id", session.id)
      .single();

    if (order?.status === "pending") {
      await fulfillCredits(session.customer, 500);
      await db.from("orders").update({ status: "completed" }).eq("stripe_session_id", session.id);
    }
  }

  return NextResponse.json({ received: true });
}

When Stripe delivered duplicate webhook requests simultaneously, both serverless functions read order.status as 'pending', resulting in 1,000 credits being fulfilled instead of 500.

The Remediated Pattern

We replaced this with a two-phase idempotency gate using a dedicated processed_webhook_events tracking table:

-- Atomic idempotency table with lifecycle status
CREATE TABLE public.processed_webhook_events (
  event_id TEXT PRIMARY KEY,
  status TEXT NOT NULL CHECK (status IN ('processing', 'completed')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
// HARDENED: Two-phase state tracking prevents concurrent duplicate fulfillment
export async function POST(req: NextRequest) {
  const payload = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(payload, sig, endpointSecret);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // 1. Attempt to insert 'processing' claim
  const { error: claimError } = await db
    .from("processed_webhook_events")
    .insert({ event_id: event.id, status: "processing" });

  if (claimError) {
    // 23505: Unique constraint violation indicates a concurrent or previous delivery
    if (claimError.code === "23505") {
      const { data: existing } = await db
        .from("processed_webhook_events")
        .select("status")
        .eq("event_id", event.id)
        .single();

      // If already completed, acknowledge duplicate safely with HTTP 200
      if (existing?.status === "completed") {
        return NextResponse.json({ received: true, duplicate: true });
      }

      // If still processing in another concurrent worker, return 500 so Stripe retries later
      return NextResponse.json(
        { error: "Event processing in flight" },
        { status: 500 }
      );
    }

    // Transient database or network error: return 500 so Stripe retries
    return NextResponse.json({ error: "Claim failed" }, { status: 500 });
  }

  // 2. Execute fulfillment
  try {
    if (event.type === "checkout.session.completed") {
      await fulfillOrder(event.data.object);
    }

    // 3. Mark completed after successful fulfillment
    await db
      .from("processed_webhook_events")
      .update({ status: "completed", updated_at: new Date().toISOString() })
      .eq("event_id", event.id);

    return NextResponse.json({ received: true });
  } catch (err) {
    // Fulfillment failed: delete claim so Stripe retries can be processed, then return 500
    await db.from("processed_webhook_events").delete().eq("event_id", event.id);
    return NextResponse.json({ error: "Fulfillment failed" }, { status: 500 });
  }
}

In high-volume production architectures, combine this state gate with database-level transactions (such as PostgreSQL RPC functions) or durable background queues (such as pg-boss, BullMQ, or AWS SQS) so the event claim and subscription state commit atomically.

3. Teardown 3: Connection Pool Starvation in Serverless Functions

In our synthetic load tests, executing thirty concurrent requests against Next.js API routes caused immediate remaining connection slots are reserved for non-replication superuser connections exceptions in PostgreSQL.

The AI assistant initialized direct PostgreSQL client drivers (postgres.js or pg) on port 5432 inside every Route Handler. In serverless and edge environments, incoming concurrent requests spawn separate worker executions, rapidly exhausting the database connection limit.

We resolved this by routing all database operations through the Supavisor transaction pooler on port 6543 and configuring max: 1 per serverless instance, or leveraging the Supabase HTTP Data API for short-lived transactional queries.

4. Teardown 4: Unvalidated Request Payloads (TypeScript as vs Zod .strict())

The AI-generated application accepted workspace invite requests via /api/workspaces/invite. The AI handler relied on TypeScript type assertions without runtime validation:

// VULNERABLE: TypeScript 'as' is completely erased at runtime
interface InvitePayload {
  workspaceId: string;
  email: string;
  role: "admin" | "member";
}

export async function POST(req: NextRequest) {
  let body: any;
  try {
    body = (await req.json()) as InvitePayload;
  } catch {
    return NextResponse.json({ error: "Malformed JSON" }, { status: 400 });
  }
  
  // FLAW: Raw request object is spread directly into the database insert (CWE-915 mass assignment)
  await db.from("workspace_members").insert({
    ...body,
  });
}

We replaced the type assertion with runtime Zod schema validation with chained .strict(), rejecting unpermitted keys:

import { z } from "zod";

const InviteSchema = z.object({
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
}).strict();

export async function POST(req: NextRequest) {
  let json: unknown;
  try {
    json = await req.json();
  } catch {
    return NextResponse.json({ error: "Malformed JSON payload" }, { status: 400 });
  }

  const result = InviteSchema.safeParse(json);
  if (!result.success) {
    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
  }
  
  // Safe, verified input
  const { email, role } = result.data;
  // ... execute verified insert
}

Post-Rescue Verification Criteria

Before signing off on production readiness, our code rescue verification suite requires passing the following automated gates:

  • Cross-Tenant Authorization Gate: pgTAP regression test fixtures must assert that non-member roles receive zero records across all workspace tables.
  • Webhook Concurrency Gate: Concurrency simulation delivering identical Stripe event IDs must return HTTP 200 with duplicate: true for subsequent deliveries without duplicate fulfillment.
  • Database Connection Pool Gate: Database calls in serverless handlers must execute through the transaction pooler on port 6543 to eliminate connection exhaustion under concurrency.
  • Input Validation Gate: Zod .strict() schemas must reject requests with unexpected properties or invalid formats with HTTP 400.

Next steps

Evaluating an AI-generated codebase against production standards requires systematic architectural testing across authentication, concurrency, and data boundaries. To inspect database-level policy defenses in depth, walk through our hands-on Supabase RLS security audit walkthrough. If credentials or database URIs were exposed during prototyping, follow our containment runbook for secrets committed by AI coding tools.

If your team is preparing to launch a prototype built with AI coding assistants, explore Aatvi's AI Code Rescue engineering services. We audit codebases, close launch blockers directly in your repository, and implement automated CI verification gates to ensure production stability.

Source notes

Start here: AI Code Rescue audit and stabilization.