Two days ago someone gave a student 90,000,000 points in NudgeCue, my free classroom app. It was one of 101 oversized awards sent in about 23 minutes.
Nothing was hacked. Every security rule held. They only touched their own class, and the app couldn't stop it, because the rules only said who could add points, never how many.
If your app was built with an AI coding tool and talks to Supabase (or Firebase, or any database the browser can reach), you probably have the same gap. This post shows how it happens, and the three checks we added at the database level so it can't happen again.
Why the browser can't be your bouncer
In a typical vibe-coded app, the browser talks straight to the database. Supabase gives your app a public key, and the security comes from row level security (RLS): rules on each table that say which rows each signed-in person can read and write.
That's the right setup, and Supabase's own docs say the public key is safe to put in the browser when RLS is on. Getting this wrong is common. Earlier this year, a vibe-coded social network called Moltbook was found with RLS switched off, which gave anyone read and write access to the whole production database.
So turning on RLS is step one. Our app had RLS on every table. It still wasn't enough.
Here's the gap. Our rule for the points table said, roughly: "a teacher can add points for students in their own classes". The app's buttons only ever send small numbers. The biggest real award is 100 points tapped ten times, so 1,000. But the buttons are just one way to call the database. Anyone signed in can open the browser's developer tools, or write a five-line script, and send whatever they like, as long as it passes the rules. And the rules didn't care about the number.
Your app's interface is a suggestion. The database is the law. If a limit only exists in your front end, it doesn't really exist.
What happened in our app
On October 27, one signed-in account sent 101 point awards straight to the database for six students in one of its own classes. The biggest was 90,000,000. The highest student total went over 93 million.
What RLS did get right: no other teacher's data was touched. The damage stayed inside one class. But it showed us two more problems.
- Any number could be added. Nothing stopped an award of 90 million.
- Totals could be edited directly. Each student has a running total that the database keeps up to date from their point history. But the total was an ordinary column, so a signed-in teacher could also just set it to anything. Nobody had done that yet. It was the next obvious thing to try.
Fix 1: put the real limits in the database
A CHECK constraint is a rule the database enforces on every row, no matter who sends it or how. We added one line:
alter table public.student_points
add constraint student_points_award_cap
check (points <= 1000) not valid;
Two details matter:
- Only the limit you actually need. We capped adding points, not removing them, because the app's "reset points" button works by adding a big negative number. A cap on both sides would have broken it. Look at what your app really sends before you pick numbers.
not validmeans the rule applies to new rows only. Existing rows stay exactly as they are, so adding the constraint can't fail halfway on old data you haven't cleaned up.
We tested it inside a transaction that rolls back, so nothing was saved: +5,000 was refused, +1,000 went through, and a -50,000 reset still worked.
Check yours: for every number, text field and status your app writes, ask "what's the biggest, smallest or strangest value the app itself ever sends?" Then write that down as a constraint.
Fix 2: make calculated columns read-only
Some columns should only ever be worked out by the database: totals, balances, counts, scores, "is paid", "is verified". If a user can write to them directly, they can skip every other rule.
We added a trigger that runs before every insert or update on the students table. For normal signed-in users it starts new students at 0 and puts back the old total on any update:
create or replace function public.keep_student_total_points()
returns trigger language plpgsql set search_path = '' as $$
begin
if current_user in ('anon', 'authenticated') then
if tg_op = 'INSERT' then
new.total_points := 0;
elsif new.total_points is distinct from old.total_points then
new.total_points := old.total_points;
end if;
end if;
return new;
end;
$$;
create trigger keep_student_total_points
before insert or update on public.students
for each row execute function public.keep_student_total_points();
The real updates still work, because the database's own bookkeeping, which adds up the point history, doesn't run as anon or authenticated. We tested that too, rolled back: a direct update to a total was ignored, a new student given 5,000 points started at 0, and normal awards of +10 and -20 still changed the total.
Check yours: search your tables for any column you'd describe as "calculated", "summed" or "set by the system". If the browser can write it, lock it.
Fix 3: check every day that the rules still hold
Rules you can't see working are rules you'll stop trusting. So our daily stats job (a scheduled agent that already pulls the app's numbers each morning) now runs one extra read-only query:
select
(select count(*) from public.students s
where coalesce(s.total_points, 0) <>
coalesce((select sum(points) from public.student_points p
where p.student_id = s.id), 0)) as totals_that_dont_match,
(select count(*) from public.student_points
where points > 1000
and created_at > now() - interval '1 day') as awards_over_the_cap;
If either number is above zero, it goes at the top of the morning report and gets logged as an issue. The incident is in the log too, so once a week we can look back and see whether anything repeats.
The first morning it ran, it reported the 101 awards from the day before, which was exactly right. The next morning, both numbers were zero.
What we didn't do
We didn't rewrite the app to send everything through our own server. For a small app, the database rules above give you most of the protection for a few lines of SQL. We also left the strange totals as they were, since nobody else was affected. The new rules stop it happening again.
The short version
- RLS answers "who can touch this row". Turn it on everywhere, and test it as a signed-out visitor.
CHECKconstraints answer "what values are allowed". Copy your app's real limits into the database.- Triggers answer "which columns can users never set". Lock anything the database calculates.
- A daily count of rule-breaking rows tells you the rules are still working.
If your app also feels slow now that real people use it, the same database got faster in one day of fixes. If you're earlier on and it still looks AI-made, start with how to make your vibe-coded app not look vibe-coded. And the free vibe coding starter pack covers the whole path from idea to live app.
References
- Supabase: Securing your data - Why the public key is safe in the browser only when row level security is on.
- PostgreSQL: Constraints - How CHECK constraints work.
- PostgreSQL: ALTER TABLE - Adding a constraint with NOT VALID so existing rows aren't checked.
- Infosecurity Magazine: Vibe-coded Moltbook exposes user data, API keys and more - What happens when RLS is off entirely.