How to Harden Vibe-Coded Next.js 15 & Bolt.new Apps for Production (Supabase RLS + Stripe Webhooks) A developer published a 30-minute hardening checklist for taking AI-generated Next.js 15 and Bolt.new apps to production, covering Supabase row-level security policies, Stripe webhook signature verification with idempotent order fulfillment, and Zod input validation. The guide argues that vibe-coding tools like Bolt.new and Cursor optimize for speed to demo rather than production readiness, leaving RLS disabled and webhooks unconfigured by default. You shipped fast. Bolt.new gave you a working app in 20 minutes. Cursor wrote the features while you slept. The demo works. Then you check Supabase: RLS is disabled on all tables . Stripe webhooks? Not configured . Your users' data and payments are exposed. This is the "vibe coding" trap: AI generates features, not production guardrails. Here's the 30-minute hardening checklist I use before any AI-built app goes live. Bolt.new and Cursor optimize for speed to demo , not production readiness . They'll give you: But they skip: -- 1. Users only see their own data CREATE POLICY "Users can view own data" ON public.profiles FOR SELECT USING auth.uid = id ; -- 2. Users only update their own profile CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING auth.uid = id ; -- 3. Orders: users see only their orders CREATE POLICY "Users can view own orders" ON public.orders FOR SELECT USING auth.uid = user id ; -- 4. Order items: only via orders they own CREATE POLICY "Users can view own order items" ON public.order items FOR SELECT USING EXISTS SELECT 1 FROM public.orders o WHERE o.id = order items.order id AND o.user id = auth.uid ; -- 5. Admins bypass optional, for support CREATE POLICY "Admins full access" ON public.profiles FOR ALL USING EXISTS SELECT 1 FROM public.profiles p WHERE p.id = auth.uid AND p.role = 'admin' ; Test it: Sign in as User A, try to fetch User B's data. Should return empty. js // app/api/webhooks/stripe/route.ts import { headers } from 'next/headers'; import { stripe } from '@/lib/stripe'; import { createClient } from '@supabase/supabase-js'; export async function POST req: Request { const body = await req.text ; const signature = headers .get 'stripe-signature' ; let event; try { event = stripe.webhooks.constructEvent body, signature, process.env.STRIPE WEBHOOK SECRET ; } catch err { console.error 'Webhook signature verification failed:', err ; return new Response 'Webhook Error', { status: 400 } ; } // Handle the event switch event.type { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; await fulfillOrder session ; break; } case 'payment intent.payment failed': { const paymentIntent = event.data.object as Stripe.PaymentIntent; await handleFailedPayment paymentIntent ; break; } default: console.log Unhandled event type: ${event.type} ; } return new Response null, { status: 200 } ; } async function fulfillOrder session: Stripe.Checkout.Session { const supabase = createClient process.env.NEXT PUBLIC SUPABASE URL , process.env.SUPABASE SERVICE ROLE KEY ; // Idempotency: check if already processed const { data: existing } = await supabase .from 'orders' .select 'id' .eq 'stripe session id', session.id .single ; if existing return; // Already processed // Create order with RLS-safe service role await supabase.from 'orders' .insert { user id: session.metadata?.user id, stripe session id: session.id, amount total: session.amount total, currency: session.currency, status: 'paid', created at: new Date .toISOString } ; } Key points: stripe-signature header STRIPE WEBHOOK SECRET from Stripe Dashboard stripe session id before insert js // lib/validators.ts import { z } from 'zod'; export const createOrderSchema = z.object { items: z.array z.object { product id: z.string .uuid , quantity: z.number .int .positive .max 99 , } .min 1 .max 50 , shipping address: z.object { name: z.string .min 1 .max 100 , phone: z.string .regex /^\+? 0-9\s- {10,15}$/ , address line1: z.string .min 5 .max 200 , city: z.string .min 1 .max 100 , postal code: z.string .regex /^ 0-9 {5}$/ , country: z.string .length 2 .default 'ID' , } , } ; // In your API route: export async function POST req: Request { const body = await req.json ; const parsed = createOrderSchema.safeParse body ; if parsed.success { return Response.json { errors: parsed.error.flatten }, { status: 400 } ; } // Proceed with validated data } | Variable | Required | Notes | |---|---|---| | NEXT PUBLIC SUPABASE URL | ✅ | Public, safe in client | | NEXT PUBLIC SUPABASE ANON KEY | ✅ | Public, RLS enforced | | SUPABASE SERVICE ROLE KEY | ✅ | Secret Server only, bypasses RLS | | STRIPE SECRET KEY | ✅ | Secret Server only | | STRIPE WEBHOOK SECRET | ✅ | Secret From Stripe Dashboard | | NEXT PUBLIC STRIPE PUBLISHABLE KEY | ✅ | Public, safe in client | Never commit .env.local . Use Vercel/Netlify environment variables. RLS enabled on ALL tables with policies Test: User A cannot read User B data Stripe webhook endpoint deployed & verified Webhook signature verification implemented Idempotency keys on all payment events Server-side validation Zod on all mutations Service Role Key ONLY in server code No secrets in client bundle check network tab Error logging Sentry/LogRocket configured Stripe test mode → live mode switch verified I packaged the complete implementation: RLS migration files, webhook handlers, Zod validators, environment template, and a test script that verifies your hardening in one command. Gumroad $14.99 → $10.49 with code VIBE30 : https://ancuboy.gumroad.com/l/cursor-bolt-production-hardening-pack https://ancuboy.gumroad.com/l/cursor-bolt-production-hardening-pack