Skip to content
BlackFig

10 security holes in vibe-coded apps (and how to close them)

AntPublished Updated 5 min read

Quick answer

The holes that matter most are tables without Row Level Security, policies that let everyone in, secret keys shipped to the browser, roles stored where users can edit them, server endpoints that check login but not ownership, and unverified payment webhooks. Each has a specific fix you can test in minutes.

On this page
  1. Why it matters
  2. The checklist
  3. Common misses
  4. Next step

Why it matters

Many apps built with Lovable, Bolt or v0 use Supabase, and in a Supabase app the browser talks to the database directly. The key in your front-end code is meant to be public: Supabase describes the publishable key as safe to expose online. It is only safe because Row Level Security decides what that key can reach.

So security in these apps does not live in the login screen. It lives in database policies, a few server routes and some dashboard settings. Anyone can open the browser developer tools, copy your project URL and key, and send their own queries. The question is what comes back.

AI tools build what you describe, and nobody describes access rules. That is why these ten holes turn up so often. The guide to fixing vibe-coded apps covers the wider picture. This is the security list, in the order I check it.

The checklist

Database access

1. Row Level Security switched off. A table in the public schema without RLS can be read, changed and deleted by anyone with your project URL. Supabase's Security Advisor flags this as rls_disabled_in_public, at error level.

2. Policies that let everyone in. RLS is on, but the policy says using (true), or "any signed-in user can read every row". That is RLS in name only. Supabase flags always-true policies as permissive_rls_policy. Fixed looks like one policy per operation, scoped to a role and tied to the owner:

alter table public.invoices enable row level security;
 
create policy "Owners can read their invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = owner_id );
 
create policy "Owners can create their invoices"
on public.invoices for insert
to authenticated
with check ( (select auth.uid()) = owner_id );

using decides which existing rows a user can see or touch. with check decides whether a new or changed row is allowed. An insert policy without with check on the owner column lets a user create rows that belong to someone else.

3. Views and functions that skip RLS. Views are created by the postgres user, so by default they ignore the policies on the tables underneath. On Postgres 15 and later, create them with security_invoker so the caller's policies apply. Functions marked security definer run with their creator's privileges, and if they sit in an exposed schema anyone can call them through the API.

create view public.invoice_totals
with (security_invoker = true)
as select owner_id, sum(total_pence) as total_pence
from public.invoices
group by owner_id;

Database access

  • Open Supabase's Security Advisor and clear every error-level lint.
  • Every table in the public schema has RLS enabled.
  • No policy uses using (true) unless the table is meant to be public, such as published blog posts.
  • Insert and update policies have a with check clause on the owner column.
  • Views use security_invoker = true, or are not in an exposed schema.
  • Security definer functions live in a private schema with search_path set to an empty string.

Secrets

4. Secret keys in the browser. Any variable prefixed NEXT_PUBLIC_ in Next.js or VITE_ in Vite is copied into the JavaScript every visitor downloads. Vite's docs say outright that these variables should not contain API keys. A Supabase secret key or legacy service_role key bypasses RLS entirely, so one leaked key undoes everything in the section above. The same goes for Stripe secret keys and AI provider keys, which let anyone spend your money.

Moving the call to the server, rotating the key and cleaning up git history is a job of its own, so I have written it up separately: how to check for and fix exposed API keys.

Secrets

  • No secret key has a NEXT_PUBLIC_ or VITE_ prefix.
  • Searching the built JavaScript for the first characters of each secret key finds nothing.
  • Any key that was ever exposed has been rotated, not just deleted from the code.
  • No .env file with real values is committed to the repository.

Auth and roles

5. Roles users can edit. A common AI-generated admin check reads user_metadata.role. Supabase's docs warn that user metadata is editable by the user without any checks: one call to supabase.auth.updateUser({ data: { role: "admin" } }) from the browser console and they are in. Supabase flags policies that read it as rls_references_user_metadata.

Store roles in app_metadata, which users cannot update, or in a roles table only your server can write to:

create policy "Admins can read all invoices"
on public.invoices for select
to authenticated
using ( ((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin' );

Claims in the token only change when the session refreshes, so a role you remove may linger until then. For anything sensitive, check a roles table instead.

6. Redirects you don't control. Supabase sends users back to your Site URL after confirming an email or resetting a password, and only to redirectTo addresses on your Redirect URLs list. AI-built apps often leave the Site URL on localhost and add over-broad wildcards. In your own callback route, check that the next parameter is a path on your site before redirecting, or a crafted link can bounce your users to someone else's page:

const next = searchParams.get("next") ?? "/";
// Only allow relative paths on this site, never "//other-site.com"
const safeNext = next.startsWith("/") && !next.startsWith("//") ? next : "/";

Auth and roles

  • No policy or admin check reads user_metadata.
  • Changing your own user metadata from the browser console grants nothing.
  • The Site URL is your production domain, not localhost.
  • Redirect URL wildcards are limited to your own domains and preview pattern.
  • Every callback route validates its redirect parameter.

Server code

7. Login checked, ownership not. The page checks you are signed in, then the server action deletes whatever ID it is sent. Next.js's own security guide says Server Actions are reachable by direct POST request, and that a page-level check does not protect the actions inside it. Check identity and ownership in every action and route handler:

"use server";
import { createClient } from "@/lib/supabase/server";
 
export async function deleteProject(projectId: string) {
  const supabase = await createClient();
  const { data } = await supabase.auth.getClaims();
  const userId = data?.claims?.sub;
  if (!userId) throw new Error("Unauthorised");
 
  // The owner filter matters even with RLS, and is essential without it
  const { error, count } = await supabase
    .from("projects")
    .delete({ count: "exact" })
    .eq("id", projectId)
    .eq("owner_id", userId);
 
  if (error) throw error;
  if (count === 0) throw new Error("Not found");
}

If that server code uses a Supabase secret key, RLS is bypassed, and the explicit ownership check is the only thing standing between users.

8. Payment webhooks nobody verified. If your webhook route upgrades an account without checking Stripe's signature, anyone can post a fake "payment succeeded" event to it. Stripe needs the raw request body for verification, so read it as text:

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { stripe } from "@/lib/stripe"; // server-only Stripe client
 
export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature") ?? "";
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
 
  // Record event.id first and skip events you have already processed
  // ...handle checkout.session.completed and friends here
 
  return new Response(null, { status: 200 });
}

Server code

  • Every Server Action and route handler checks who the user is.
  • Every action that takes an ID checks the user owns that record.
  • Server code using a secret key filters by owner explicitly.
  • The Stripe webhook verifies the signature against the raw body.
  • Account upgrades happen in the webhook, not on the success page.

Abuse

9. No rate limits on expensive endpoints. An endpoint that calls an AI model, sends email or sends SMS costs you money every time it runs. Without a limit, one script can run up the bill overnight. Next.js's guidance is to rate-limit in code and also use your host's rate limiting, and it points out that counts kept in memory are per server instance, so use a shared store once you run on more than one. Set a hard monthly spend cap with each paid provider as a backstop.

Abuse

  • Endpoints that cost money per call have a per-user and per-IP limit.
  • Limits are stored somewhere shared, not in server memory.
  • Each paid API has a spend cap or budget alert set in its dashboard.

Output

10. User content rendered as HTML. React escapes text by default, which protects you. dangerouslySetInnerHTML switches that off. React's docs warn that using it with untrusted markup makes it trivial to introduce a cross-site scripting hole, and AI tools reach for it whenever you ask for rich text. Render user content as text, or sanitise it first:

"use client";
import DOMPurify from "dompurify";
 
export function Bio({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

Output

  • Search the code for dangerouslySetInnerHTML and check where each value comes from.
  • Anything written by a user is sanitised before it is rendered as HTML.
  • Markdown from users is rendered with raw HTML disabled or sanitised.

Common misses

These are the ones that survive a first pass, because they sit just outside the obvious places.

  • Storage buckets. A public bucket with a broad select policy lets anyone list every file in it, not just open the ones they have links to. Supabase flags this as public_bucket_allows_listing. Private uploads such as invoices or ID documents belong in a private bucket with owner-based policies.
  • Hiding the button instead of blocking the action. Removing the delete button for non-admins changes nothing if the endpoint still accepts the request.
  • Old keys in git history. Deleting a key from the code leaves it in every earlier commit. Rotate it.
  • Preview deployments with production keys. If every preview branch talks to the live database, every half-finished feature is live too.
  • One check, then many prompts. Each new AI change can add a table without RLS or a new action without an ownership check. Re-run the advisor after every significant change, not once before launch.

Next step

Start with Supabase's Security Advisor, then work down the list above with two accounts open in two browsers, trying to see and change each other's data. On a small app, that is often an afternoon's work.

If you want a second opinion first, the free vibe-code health check gives a quick read. If you find holes and would rather not close them yourself, a Rescue Sprint starts with exactly this audit, fixes the dangerous items first, and leaves you with notes on what to watch when you keep prompting. The authentication fix guide covers the sign-in side in more depth.

Sources

The vibe-code rescue checklist, as a PDF

About 40 checks across auth, data, payments, errors, mobile and secrets, on two pages.

You get the PDF now and two short follow-ups over the next week. Reply “stop” and the rest never arrive.

Questions founders ask

Is the Supabase key in my front-end code a security problem?

Not if it is the publishable (or legacy anon) key and every table has Row Level Security with proper policies. That key is designed to be public. A secret or service_role key in the browser is a serious problem.

Do I need a penetration test before launch?

For most early products, no. Working through this checklist and running Supabase's Security Advisor catches the most common holes. A formal test makes sense once you hold sensitive data or sell to companies that ask for one.

Are apps built with Lovable or Bolt less secure than hand-written ones?

Not inherently. The tools write the code you ask for, and nobody asks for access rules. The same holes appear in hand-written prototypes. The difference is how quickly an AI-built app reaches real users before anyone checks.

Ant

Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me