How to Harden Vibe-Coded Next.js 15 & Bolt.new Apps for Production (Supabase RLS + Stripe Webhooks)

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.

The Problem: AI Codes Features, Not Boundaries

Bolt.new and Cursor optimize for speed to demo, not production readiness. They'll give you:

  • Working auth flows
  • Database schemas
  • Payment integration UI

But they skip:

  • Row Level Security policies (RLS off by default in Supabase)
  • Stripe webhook signature verification
  • Idempotency keys for payment retries
  • Server-side validation (they trust the client)

1. Supabase RLS: The 5 Policies Every App Needs

-- 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.

2. Stripe Webhooks: Verify Every Event

// 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:

  • Always verify stripe-signature header
  • Use STRIPE_WEBHOOK_SECRET from Stripe Dashboard
  • Implement idempotency (check stripe_session_id before insert)
  • Use Service Role Key for server-side writes (bypasses RLS safely)

3. Server-Side Validation: Never Trust the Client

// 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 } 

4. Environment Hardening Checklist

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.

5. The "Ship Tonight" Checklist (Copy-Paste)

[ ] 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 

The Pack: All of This, Pre-Built

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