DEV Community

Marco Gundlach
Marco Gundlach

Posted on

Your vibe-coded app already has a data leak (and the code compiles fine)

The first version always works. That is the trap.
You describe a small internal tool, wait two minutes, and there it is, running in your browser with your real data in it. It feels like a magic trick. And because it works, you ship it. Why wouldn't you. It works.
Here is the problem. "It works" and "it is safe" are two completely different claims, and vibe coding is very good at the first one while staying silent about the second.
I build small and mid sized apps mostly through natural language, usually Next.js with Supabase behind it. This is the single most common security hole I see, in my own early drafts and in the projects people bring to my trainings. It shows up in almost every Supabase app that was built fast. It never shows up in the demo. It always shows up the moment real users arrive.
Let me show you exactly what it looks like.
What you asked for
You are building a tiny internal dashboard. You prompt something reasonable:

Add a page that lists our customers from Supabase. Table called customers with name, email, and mrr. Show them in a sortable table.

The model happily gives you a clean client component.

'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

export default function Customers() {
  const [rows, setRows] = useState<any[]>([])

  useEffect(() => {
    supabase
      .from('customers')
      .select('*')
      .then(({ data }) => setRows(data ?? []))
  }, [])

  // ...render the table
}`

And earlier, when you set up the database, it handed you this SQL to paste into the Supabase SQL editor:
`create table customers (
  id uuid primary key default gen_random_uuid(),
  name text,
  email text,
  mrr numeric,
  created_at timestamptz default now()
);
Enter fullscreen mode Exit fullscreen mode

You ran it. The page loads. The customers show up. Everything is green.
Nothing here is broken in the "the code compiles" sense. The model did its job. It made one assumption that happens to be false for your setup, and it never said a word about it.
Why it looks completely fine
In the demo you are the only user. You are logged into your own machine, looking at your own test data. There is no second person on the other side of the app trying to do something you did not intend. So the dangerous behaviour simply never gets a chance to appear.
This is the thing about security holes built this way. They are not loud. The app does not crash. There is no red error. The code does exactly what you asked, and what you asked happened to be unsafe.

The hole

Two facts that the generated code never put next to each other.
First, that NEXT_PUBLIC_SUPABASE_ANON_KEY is public on purpose. Next.js inlines any NEXT_PUBLIC_ variable straight into the JavaScript bundle that ships to the browser. So does the project URL. Anyone who opens your site can read both out of the network tab in about five seconds. That is by design. The anon key is not a secret.
Second, the only thing that makes a public anon key safe in Supabase is Row Level Security. RLS is what decides which rows the anon key is actually allowed to touch. And on a table you created with raw create table SQL, RLS is off. No policies. No gate. The table is fully exposed through Supabase's auto-generated REST API.
Put those two facts together and you get this. Your customer table, with names, emails, and revenue per account, is readable by anyone on the internet who can copy two values out of your own front end.
They do not even need your app. Here is the entire attack.

curl 'https://YOURPROJECT.supabase.co/rest/v1/customers?select=*' \
  -H "apikey: THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE" \
  -H "Authorization: Bearer THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE"
Enter fullscreen mode Exit fullscreen mode

That returns your whole table as JSON. Your UI, your sorting, your nice components, none of it matters. The REST endpoint sits underneath all of that and it answers to anyone holding the public key. Which is everyone.
And it is not just reads. With no policies, depending on your grants, the same key can often insert, update, and delete. So now it is not only a leak. It is a write surface.
To be fair to Supabase, the dashboard does warn you. If you create a table through the Table Editor UI, RLS gets switched on by default and an "Unrestricted" badge yells at you when it is off. The security advisor flags it too. But here is the catch. When you vibe code, you do not click through the UI. You paste the SQL the model gave you straight into the SQL editor. That path skips the guardrails the dashboard built for exactly this moment.
The tool tried to protect you. The workflow routed around the protection.

Why this keeps happening with AI specifically

Models optimize for code that runs. RLS is invisible to "does it run". An app with wide open tables runs perfectly. It demos perfectly. The feedback loop the model is trained on, generate, see it work, move on, has no step in it where the missing policy ever surfaces.
So unless you put security into the spec yourself, the model will quietly choose the version that works today and leaks tomorrow. Not out of malice. It just has no reason to pick otherwise, and "it works" is the only signal in the room.
This is the general shape of the whole problem, by the way. The model fills every gap you leave with a plausible default. Most defaults are fine. Some of them ship your customer list to the public internet.

The fix

Three layers, from "do this right now" to "do this from now on".

1. Turn RLS on for every table. No exceptions.

alter table customers enable row level security;
Enter fullscreen mode Exit fullscreen mode

Run that and reload your app. It will break. The table goes silent and your dashboard shows nothing. That is correct. That is RLS doing its job: deny by default. An empty table in your UI right now is infinitely better than a full table in someone's curl later.

2. Add explicit policies that say who can see what.

Deny-by-default is only useful once you grant the narrow thing you actually want. Assuming each customer row belongs to a user, give the table an owner_id and scope reads to the logged-in user.

alter table customers add column owner_id uuid
  references auth.users (id) default auth.uid();

create policy "Read own customers"
on customers
for select
to authenticated
using (auth.uid() = owner_id);
Enter fullscreen mode Exit fullscreen mode

Now an anon request gets nothing, an authenticated user gets only their own rows, and the same curl from earlier returns an empty array. Test it yourself with the public key and confirm you get []. If you get rows, you are not done.

3. Keep sensitive work on the server, and keep the service role server-only.

The model defaulted to a client component, which is fine for plenty of internal tools once RLS is solid. But for anything sensitive, move the data access into a server component or a route handler so the query never runs in the browser at all. And if you use the service_role key, which bypasses RLS entirely, it lives in server-only environment variables and never, ever gets a NEXT_PUBLIC_ prefix. One slip there and you have handed out a master key.

// app/customers/page.tsx  (server component, runs on the server only)
import { createServerClient } from '@/lib/supabase/server'

export default async function Customers() {
  const supabase = await createServerClient()
  const { data: rows } = await supabase.from('customers').select('*')
  // RLS still applies, and nothing leaks into the client bundle
}
Enter fullscreen mode Exit fullscreen mode

Make the model do this for you
You should not have to remember this every time. Put the rule into the prompt once and let it carry.

Any time you create a Supabase table, enable Row Level Security in the same step and write explicit policies scoped to the current user. Never expose a table through the anon key without RLS. Never put the service_role key in a NEXT_PUBLIC variable. If a table needs to be readable without auth, call it out explicitly and ask me first.

That last sentence is the important one. It turns a silent default into a decision you get to make on purpose.

The actual lesson
The dangerous sentence in vibe coding is not "I don't know how this works". It is "well, it runs".
Running tells you the syntax is valid. It tells you nothing about whether the thing is safe, or whether it does what you actually meant under conditions you did not test. Generated code is a draft. You own it the moment you ship it, which means you read it, and when you hit something you do not understand, you ask the model to explain it before you accept it. Those five minutes on the RLS line are the cheapest insurance you will ever buy.
Vibe coding did not make this mistake more common because the tools are bad. It made it more common because it removed the friction that used to slow you down long enough to notice. The speed is real. So is the bill, if you are not watching.
Go check your tables. Right now. I will wait.
If you found one, you are not alone, and I would genuinely like to hear how it slipped through. The comments are open

Top comments (0)