cd /news/ai-tools/how-to-harden-vibe-coded-next-js-15-… · home › topics › ai-tools › article
[ARTICLE · art-148980] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

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.

by read4 min views1 publishedOct 11, 2026

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.

// 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 headerSTRIPE_WEBHOOK_SECRET from Stripe Dashboardstripe_session_id before insert)

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

── more in #ai-tools 4 stories · sorted by recency
── more on @bolt.new 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-harden-vibe-c…] indexed:0 read:4min 2026-10-11 · —