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-signatureheader - Use
STRIPE_WEBHOOK_SECRETfrom Stripe Dashboard - Implement idempotency (check
stripe_session_idbefore 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
Top comments (0)