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
| Problem | Bolt | Lovable |
|---|---|---|
| A client can edit profile fields that decide what they see | Critical Role, company, active flag | High Company and active flag |
| Deactivation doesn't hold | High Deactivated staff keep access | High Users can switch themselves back on |
| Anyone can create an account | Medium | Medium |
| The public database roles (signed-out visitors, and anyone signed in) hold every right on every table | Low | Low |
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.
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
| ID | Finding | Severity | After two fix rounds |
|---|---|---|---|
| F-01 | Any client can make themselves staff and read, change or delete every company's data (Likely anyone at the sign-in page) | Critical | Fixed |
| F-02 | Deactivated staff aren't locked out | High | Fixed |
| F-03 | The daily summary will never send | High | Likely fixed: a manual call delivered; I switched the schedule off after testing, so it never ran on its own |
| F-04 | Anyone can trigger the summary to every client, with no limit | Medium | Fixed |
| F-05 | The invitation function checks no permission and reveals who's invited | Medium | Fixed |
| F-06 | Anyone can create an account and make the portal email any address | Medium | Not fixed: fixed in round 1, undone in round 2 |
| F-07 | The public database roles hold every right on every table | Low | Partly fixed |
| F-08 | Clients can read invitation tokens (each invitation's secret value) and co-workers' invited emails | Low | Fixed |
| F-09 | Sign-in asks for a code; the email sends a link (not a security problem) | Low | Partly fixed: they agree now, but the link doesn't sign anyone in, and one staff line still says "one-time code" |
| F-10 | A client can save a file record pointing at another company's folder (the file stays unreadable) | Low | Fixed |
| R-01 | Sign-in no longer works, so nobody can get in through the portal's own page | High | Open |
| R-02 | A stranger can register an address first and block that person's later invitation (Likely) | Medium | Open |
| R-03 | Supabase's sign-in endpoint revealed who had been invited | Medium | Partly fixed: it still tells account holders from unknown addresses |
| R-04 | A sign-in that sends an email takes noticeably longer than one that doesn't | Note | Open |
| R-05 | The summary's secret was copied out of Supabase Vault into a plain table (locked down today) | Note | Open |
| R-06 | Summary emails show times in UTC (in the original build, found once the summary ran) | Note | Open |
| R-07 | The summary email's button linked to the database's address instead of the portal | Low | Fixed |
Lovable: 6 findings, then 3 more from its fix
| ID | Finding | Severity | After one fix round |
|---|---|---|---|
| F-01 | A client who knows another company's ID can move into it and read its work | High | Fixed |
| F-02 | A deactivated user can switch themselves back on | High | Fixed |
| F-03 | Sign-in asks for a code the email never sends (not a security problem) | Low | Fixed |
| F-04 | Anyone can create an account and make the portal email them | Medium | Not fixed: only the screen changed; Supabase sign-up is still open |
| F-05 | Helper functions (the small database functions the access rules call) answer any signed-in user's questions about other users' role, status and company | Low | Fixed |
| F-07 | The public database roles hold every right on every table | Low | Open |
| R-01 | A client can still change their profile email, where their company's daily summary goes | Low | Open |
| R-02 | A deactivated user can still sign in, though they see nothing; the People page says they can't | Note | Open |
| R-03 | One "this portal is invite-only" message covers every sign-in failure (Likely) | Note | Open |
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
Lovable
The same counts as a table
| Severity | Bolt as built | Bolt after 2 rounds | Lovable as built | Lovable after 1 round |
|---|---|---|---|---|
| Critical | 1 | 0 | 0 | 0 |
| High | 2 | 2 (1 new, 1 likely fixed) | 2 | 0 |
| Medium | 3 | 3 (2 new) | 1 | 1 |
| Low | 4 | 2 | 3 | 2 (1 new) |
| Problems | 10 | 7 (3 new) | 6 | 3 (1 new) |
| Note | 0 | 3 (2 new) | 0 | 2 (2 new) |
7 What you can check yourself
Use a staging copy with its own database, never your live app.
select tablename, policyname, cmd, qual, with_check from pg_policies where schemaname = 'public' and cmd in ('UPDATE', 'ALL');andselect 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.- 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.
- 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. - Type an address you never invited into sign-in. If an email arrives, anyone can make an account. Supabase says to set
shouldCreateUsertofalsemeasured (passwordless email page); turn off open sign-up in its settings too. - Read the sign-in email beside the screen. A code screen needs
{{ .Token }}in the template measured (same page). select status, return_message, start_time from cron.job_run_details order by start_time desc limit 5;thenselect 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
- PostgreSQL 18 documentation, "GRANT" (read October 23, 2026). www.postgresql.org
- Supabase documentation, "Passwordless email logins" (undated; read October 23, 2026). supabase.com
- Supabase documentation, "Security testing of your Supabase projects" (undated; read October 23, 2026). supabase.com
- UpGuard, "Everything Everywhere: Systemic Data Exposure in Supabase Apps", September 24, 2026, updated October 14, 2026 (read October 23, 2026). www.upguard.com
- 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, $149Or check it yourself first: the security self-check walks through eleven checks, most of them in your Supabase dashboard or your GitHub repository.