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

> Source: <https://dev.to/housharenet/how-to-harden-vibe-coded-nextjs-15-boltnew-apps-for-production-supabase-rls-stripe-webhooks-25op>
> Published: 2026-10-11 02:10:57+00:00

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)
