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.

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:
- 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.
- Non-idempotent payment webhooks: Stripe webhook handlers lacked transactional idempotency gates, permitting concurrent retry requests to double-credit user subscriptions.
- Database connection exhaustion: Direct PostgreSQL client drivers in serverless API routes exhausted database connection pools under modest concurrent traffic.
- 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: truefor 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
- Stripe Webhook Best Practices covers event idempotency and transaction locks.
- Next.js Route Handlers Documentation outlines serverless request handling.
- The Zod Schema Validation Library provides runtime type checking and input sanitation.
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes catalogs mass-assignment vulnerabilities.
Start here: AI Code Rescue audit and stabilization.
