Watson Standard1
Research · Apps built with AI tools Written by Bryce Watson

← All research

I put two AI-built client portals through a security review. Both let a client reach another company's work.

On October 23, 2026, I gave two AI app builders, Bolt and Lovable, the same request: an invite-only portal for a small agency, where each client company sees only its own projects and files. Then I had a fresh AI agent review each build the way I'd run a full review. I didn't plant anything. The only change to either build was one database row adding the first staff user, because neither app had a way to create one. If your app keeps data for more than one company, start with what a user can change on their own profile: in both builds, that's where the leak was.

The short version

I got one build from each, and neither was ready for a real client. In Bolt's, any client could make themselves staff and read, change or delete every company's work, and most likely so could anyone who reached the sign-in page. In Lovable's, a client who knew another company's ID could move into it and read its work, though the review found no way to learn one. Both let anyone create an account. The review turned up 10 problems in Bolt's build and 6 in Lovable's. One in each is a sign-in wording mix-up, not a security bug, and one in each is Supabase's default table rights.

Published ; updated with what a follow-up architecture read found. Tags like measured say where a fact comes from; the key ends section 8.

1 The request

The request is modeled on a real portal job posted that week and sticks to what the app does. Its only security line is the last one. Both builders got it word for word measured:

Build an invite-only client portal for my small agency. We write documents for client companies, and each client company has a few people who need to log in.

There are two kinds of users: staff (my team) and clients (people at a client company). Each client belongs to one client company.

  • Sign-in is by email with a one-time code. No passwords.
  • Only invited people can get in. Staff invite client users to a company by email.
  • Staff can deactivate a user, and a deactivated user can't use the portal anymore.
  • Staff create projects for a client company and post status updates on them.
  • Clients see their own company's projects and updates, can comment, can upload files (drafts, PDFs) to a project, and can download files staff share with them.
  • Staff can see and manage everything, and can add other staff.
  • Staff have an admin page for companies, users, invitations and deactivation.
  • Every weekday at 8am, email each client a summary of the last day's updates on their company's projects. Use Resend for email; I'll give you the API key.
  • Use Supabase for the database, sign-in and file storage.

Clients must only ever see their own company's work.

Every reply to a builder followed four fixed rules: approve every step it asks about, say "Use your best judgment" when it offers a choice, say "Continue" when it stalls, and never publish. Bolt finished with no follow-ups and changed its database without asking. Lovable got two "Continue" messages (only the first did anything) and asked before each of its 7 database and storage steps measured.

Neither builder's security scan ran. Lovable has one built in, but I wanted the review to see its first pass as built, so the rules said not to apply its fixes, and nobody started the scan. Bolt didn't offer one measured.

Severity: Critical, someone could read, change or delete other people's data without special access; High, serious soon after launch, or a Critical with a condition; Medium, real but limited; Low, fix when you're in that code; Note, nothing to fix now.
Confidence: Verified, the review made it happen; Likely, the code clearly allows it but nobody triggered it.

2 A client makes themselves staff

Critical Verified Bolt kept each person's role, company and active flag on their own row in a profiles table, and the rule for updating it only checked that the row was theirs. So the review agent signed in as a Company A client and sent one request straight to the database's API, with the sign-in token the client's browser already holds:

PATCH /rest/v1/profiles?id=eq.<client's own user id>
apikey: <public key>
Authorization: Bearer <client's own sign-in token>
{"role":"staff","company_id":"<Company B id>","active":true}

The database answered 200 (success), and the row now said "role":"staff". The same client then read every user profile in both companies, and its project list grew from one project to both companies' measured. From there the staff rules applied to them. Going by those rules, they could also change or delete any company's projects, updates, comments and files, and invite new staff derived. The agent set the account back afterward.

Sign-up was open, so a stranger could make an account and get an inactive client profile, send the same request, and keep full access while still inactive, because the staff check ignored the active flag. The review reproduced each step but never ran the whole chain, so "anyone who reached the sign-in page" is Likely derived.

The fix: let people change only what's theirs, like a display name. Keep role, company and active status out of reach in the database itself, with a column grant or a trigger. The page hiding the field doesn't help: the request in this section never touched the page.

3 A client moves into another company

High Verified Lovable kept roles in a separate user_roles table that ordinary users can't write to, so a client couldn't make themselves staff. Their company was still on their own profile row, though, under a rule that only checked the row was theirs. Signed in as the Company A client, the agent sent {"company_id":"<Company B id>"} to its own profile, got a 200, and then read Company B's project and its update. It worked the other way round too measured. They still couldn't change Company B's projects; only staff could. The same rule let a deactivated user send {"is_active": true} and switch themselves back on measured.

It's rated High rather than Critical because the client needs the other company's ID, a long random value. Companies, invitations and other companies' projects were all hidden from clients, and the review found no way to learn one measured. Anyone who got one from a shared link or a screenshot could use it. Lovable's fix round closed it with a trigger, and the retest confirmed that.

4 The same mistakes, twice

ProblemBoltLovable
A client can edit profile fields that decide what they seeCritical
Role, company, active flag
High
Company and active flag
Deactivation doesn't holdHigh
Deactivated staff keep access
High
Users can switch themselves back on
Anyone can create an accountMediumMedium
The public database roles (signed-out visitors, and anyone signed in) hold every right on every tableLowLow

Supabase's row-level security decides which rows each signed-in user may read and change. A rule saying "you may update your own profile row" says nothing about columns, so if that row holds your role or company, you can change those too derived. Postgres can limit updates to named columns with a column grant measured (Postgres documentation), and a trigger, a function the database runs before each change, can refuse one. Neither build used a column grant or a trigger.

One profile rule, two builds A signed-in client skips the app's pages and sends one request straight to the database's API to change a field on their own profile row. The database rule asks only whether the row is theirs, never which field changed, so the change is saved. In Bolt's build the client changes their role to staff and gets staff access to every company's work, rated Critical. In Lovable's build the client changes their company to another company's and reads its projects and updates, rated High; that needs the other company's ID, which the review found no way to learn. The fix is to limit which fields a user can change, with a column grant or a trigger. Signed-in client with their own sign-in The app's pages Skipped by the request One request straight to the database's API: PATCH OWN PROFILE ROW DATABASE RULE FOR PROFILE UPDATES Is this row yours? Yes, so allow it Which field changed? Never asked Change saved Bolt's build CRITICAL Changes role to staff Gets staff access to every company's work Lovable's build HIGH Changes company to another company's Reads its projects and updates Needs that company's ID, which the review found no way to learn The fix: limit which fields a user can change, with a column grant or a trigger.
One profile rule, two builds. The rule on each build's profile table checked only that the row was the client's own, so one request that skipped the app's pages could change the fields that decide what a client sees. Severity ratings are the review's, from the tables in section 5.

Open sign-up came from two choices both builders made: each left Supabase's default sign-up switched on, and each called sign-in with shouldCreateUser: true, which makes an account for any address measured. The table rights are Supabase's default. Row-level security held in both builds, but it's the only thing in the way, so one later change that switched it off would open that table to everyone derived.

Both sign-in screens also asked for a 6-digit code. Supabase writes that email from the project's templates, and both builders put a code screen on top of a link-only email. The link worked, so it's rated Low.

Apart from that profile edit, the basics held in both measured: a client asking the database for another company's records got nothing, files were walled off by company, signed-out visitors could read no records, and the browser carried only the public key. Mid-build, Lovable also removed the default permission that let signed-out visitors run its helper functions, so only signed-in users and the app's server could measured.

So these weren't the "tables anyone can read" leaks. UpGuard, a security company, reported in September 2026 that it had identified 16,326 databases exposing readable tables, out of about 300,000 candidate domains showing signs of Supabase reported (UpGuard). Both of these came from a rule that looked reasonable and let one field too many through. Clicking through the app shows nothing wrong, and neither does the usual check of whether company A can see company B's rows derived.

5 Every finding

Neither build sent invitation emails as built: Resend refused Bolt's sender, and Lovable's sends none. Lovable also built no comments, and only staff can upload. A later read found its download rule checks the project's title instead of the file's folder, so clients most likely can't download any file, their own company's included derived. Part of the gap between 10 findings and 6 is that Bolt built features Lovable skipped, so there was more of Bolt's to check derived.

Bolt: 10 findings, then 7 more: 6 from its own fixes, and 1 from the original build that showed up once the summary ran

IDFindingSeverityAfter two fix rounds
F-01Any client can make themselves staff and read, change or delete every company's data (Likely anyone at the sign-in page)CriticalFixed
F-02Deactivated staff aren't locked outHighFixed
F-03The daily summary will never sendHighLikely fixed: a manual call delivered; I switched the schedule off after testing, so it never ran on its own
F-04Anyone can trigger the summary to every client, with no limitMediumFixed
F-05The invitation function checks no permission and reveals who's invitedMediumFixed
F-06Anyone can create an account and make the portal email any addressMediumNot fixed: fixed in round 1, undone in round 2
F-07The public database roles hold every right on every tableLowPartly fixed
F-08Clients can read invitation tokens (each invitation's secret value) and co-workers' invited emailsLowFixed
F-09Sign-in asks for a code; the email sends a link (not a security problem)LowPartly fixed: they agree now, but the link doesn't sign anyone in, and one staff line still says "one-time code"
F-10A client can save a file record pointing at another company's folder (the file stays unreadable)LowFixed
R-01Sign-in no longer works, so nobody can get in through the portal's own pageHighOpen
R-02A stranger can register an address first and block that person's later invitation (Likely)MediumOpen
R-03Supabase's sign-in endpoint revealed who had been invitedMediumPartly fixed: it still tells account holders from unknown addresses
R-04A sign-in that sends an email takes noticeably longer than one that doesn'tNoteOpen
R-05The summary's secret was copied out of Supabase Vault into a plain table (locked down today)NoteOpen
R-06Summary emails show times in UTC (in the original build, found once the summary ran)NoteOpen
R-07The summary email's button linked to the database's address instead of the portalLowFixed

Lovable: 6 findings, then 3 more from its fix

IDFindingSeverityAfter one fix round
F-01A client who knows another company's ID can move into it and read its workHighFixed
F-02A deactivated user can switch themselves back onHighFixed
F-03Sign-in asks for a code the email never sends (not a security problem)LowFixed
F-04Anyone can create an account and make the portal email themMediumNot fixed: only the screen changed; Supabase sign-up is still open
F-05Helper functions (the small database functions the access rules call) answer any signed-in user's questions about other users' role, status and companyLowFixed
F-07The public database roles hold every right on every tableLowOpen
R-01A client can still change their profile email, where their company's daily summary goesLowOpen
R-02A deactivated user can still sign in, though they see nothing; the People page says they can'tNoteOpen
R-03One "this portal is invite-only" message covers every sign-in failure (Likely)NoteOpen

Two setup notes, not counted against Lovable. Its daily summary (F-06 in the review) needed an admin key my local setup withheld on purpose, and its schedule called Lovable's hosted address for the project, which I never published. After the fix it runs inside the database, but its email key was never filled in and its sender falls back to Resend's test address, so no client would get it yet measured. And in this setup the fix broke inviting: the new invite path needs that admin key, and the sign-in screen no longer creates accounts, so an invited client got "Signups not allowed" measured. Even with that key, Lovable's invite creates the account without sending any email, so an invited client only hears about the portal if someone tells them measured. The retest rated it High; it's logged as a setup note because the break depends on the admin key my setup withheld.

The ratings key is at the end of section 1.

6 Asking each builder to fix its own work

Then each builder got its review back with a request to fix it. Bolt got 2 rounds and Lovable 1, and a fresh AI agent retested after each measured.

Bolt's first round stopped clients making themselves staff and made deactivation cut staff off: 6 findings fixed, 3 partly, 1 not. But the new sign-in code sent no email to anyone. Its second round brought 6 of the 10 to fixed, a 7th (the daily summary) to Likely fixed, since a manual call delivered but the schedule was switched off before it ever ran, and 2 to partly fixed. The email arrived now, but the app rejected its link, so opening its link dropped staff and clients back on the sign-in page with no error. To quiet one error message, Bolt also undid its own fix for uninvited accounts, which let a stranger register an address first and block that person's invitation (R-02). I stopped Bolt there.

Lovable's one round fixed the company move, the self-reactivation, the sign-in wording and the helper functions, mostly with a database trigger. For uninvited sign-ups it changed only its own screen, so Supabase still makes the accounts, and it left the profile email editable.

Both builders said their fixes were done. The retests found that some weren't.

One finding in the original buildOne finding created by the builder's fixesLikely fixed, still counted as open

Bolt

Severity
As built
After 2 fix rounds
Critical
1
0
High
2
2
Medium
3
3
Low
4
2
Problems
10
7
Note
0
3

Lovable

Severity
As built
After 1 fix round
Critical
0
0
High
2
0
Medium
1
1
Low
3
2
Problems
6
3
Note
0
2
What was left after each builder fixed its own work. Counted from the two tables in section 5. "As built" is every finding from the first review: Bolt's 10 and Lovable's 6. "After" counts a finding as still open when its status is Not fixed, Partly fixed, Likely fixed or Open; Bolt's daily summary (F-03) is Likely fixed, and it counts as open because its first scheduled run hadn't happened. Outlined squares are problems the builder's own fixes created, and the half-filled square is F-03. Notes need no fix now, so they sit below the problem total. Two findings the tables don't place on their own: Bolt's R-06 was in the original build but only found once the summary ran, so it's counted with the after Notes; and Bolt's R-07, created by a fix and then fixed, isn't shown. Lovable's broken invite path, which the retest rated High, is a setup note on this page and isn't counted.
The same counts as a table
Findings by severity; after counts are still open, with those created by the fixes in parentheses
SeverityBolt as builtBolt after 2 roundsLovable as builtLovable after 1 round
Critical1000
High22 (1 new, 1 likely fixed)20
Medium33 (2 new)11
Low4232 (1 new)
Problems107 (3 new)63 (1 new)
Note03 (2 new)02 (2 new)

7 What you can check yourself

Use a staging copy with its own database, never your live app.

  1. select tablename, policyname, cmd, qual, with_check from pg_policies where schemaname = 'public' and cmd in ('UPDATE', 'ALL'); and select table_name, column_name, grantee from information_schema.column_privileges where table_schema = 'public' and privilege_type = 'UPDATE'; Neither query shows triggers, so check the profiles table for those separately. If the only thing stopping someone editing their own role or company is that the page doesn't show the field, that's the bug.
  2. As a test client, send the profile request from section 2 with that client's token and read the profile back. If the role or company changed, you have the hole from section 2.
  3. Deactivate a test staff account and a test client, then send {"active": true}, or your app's field name, such as {"is_active": true}, to each one's own profile. If either works or still sees data, deactivation doesn't hold.
  4. Type an address you never invited into sign-in. If an email arrives, anyone can make an account. Supabase says to set shouldCreateUser to false measured (passwordless email page); turn off open sign-up in its settings too.
  5. Read the sign-in email beside the screen. A code screen needs {{ .Token }} in the template measured (same page).
  6. select status, return_message, start_time from cron.job_run_details order by start_time desc limit 5; then select status_code, content, created from net._http_response order by created desc limit 5; A "succeeded" in the cron log only means the call was sent; the second query shows what came back. Nothing there, an error, or anything but a 200 means the scheduled email didn't go.

8 How I got this

Both builders got the request on October 23, 2026, passed along word for word under the fixed rules above. Each build ran against its own Supabase project in a new organization, with demo users and no real data, and both front ends ran on my computer only; neither hosted version was published or tested. To use my own project, Lovable's "Enable Cloud" setting had to go from its default, "Always allow", to "Never allow", so a founder on the defaults gets Lovable's own backend and possibly different rules.

A fresh AI agent reviewed each build: first from outside with the public key and two demo client logins, then with the code, a staff login and read-only database access. It tested by hand and with small scripts against my projects only, under Supabase's policy for testing your own projects measured (security testing page), with no automated scanners. A second agent reran every Verified finding before it went on this page.

It's one build per builder, from one prompt, on one date, and the same prompt can produce a different app tomorrow. Nobody knows what the review missed, since nothing was planted to count against. The prompt's one security line may have nudged both builders toward keeping companies apart. Lovable's libraries were reinstalled fresh, so exact versions weren't checked.

Measured
Recorded in the review's own runs, or read on the source that owns the fact.
Reported
A named source says it, and I can't check it myself.
Derived
Reasoned from what was measured, not tested directly.

9 Sources

  1. PostgreSQL 18 documentation, "GRANT" (read October 23, 2026). www.postgresql.org
  2. Supabase documentation, "Passwordless email logins" (undated; read October 23, 2026). supabase.com
  3. Supabase documentation, "Security testing of your Supabase projects" (undated; read October 23, 2026). supabase.com
  4. UpGuard, "Everything Everywhere: Systemic Data Exposure in Supabase Apps", September 24, 2026, updated October 14, 2026 (read October 23, 2026). www.upguard.com
  5. The builds, reports and every request and response are private working files. The demo projects will be deleted once this is published.

10 Want me to look at your app?

If your app has teams or organizations with their own roles, the Launch Blocker Check may not be enough on its own. If I see that, I tell you before you pay, or in the list, and quote a fuller review instead. You don't have to take it. The check page lists the other cases.

The Launch Blocker Check is a private first look at your repo: I don't touch your live app or use a key I find without your written OK.

Launch Blocker Check, $149

Or check it yourself first: the security self-check walks through eleven checks, most of them in your Supabase dashboard or your GitHub repository.