9 min read
Supabase row-level security mistakes in AI-built apps
In a Supabase app, row-level security is often the only thing between the public key and your data. The policy mistakes AI builders make most often, and how to test for them.
The most common Supabase row-level security mistakes in AI-built apps are tables with RLS disabled, policies that check only that a user is signed in, authorization based on user-editable metadata, and service-role keys in client code. Enable RLS on every exposed table, write ownership-based policies, and test with the public key.
This article goes one level deeper than our broader vibe coding security checklist and the hardening sequence in how to turn a Lovable or v0 prototype into a production application. It focuses on one component many AI builders generate by default: a Supabase backend. The observations come from implementation reviews and from the equivalent rules in the ImadDhin portal, which uses a different database platform with the same failure modes. They are not claims about any specific incident.
Why RLS carries so much weight in Supabase apps
Supabase exposes your Postgres tables through an automatically generated API that the browser calls directly with a public key. That design is what makes it fast to build with: there is no backend to write for basic reads and writes. It also means the public key is, by design, in every visitor's browser.
Row-level security is the mechanism that decides which rows that public key can reach, for whom, and for which operation. If RLS is off on a table in an exposed schema, anyone with the public key can query it. If a policy is too broad, the protection exists on paper only. In many AI-built apps there is no other server layer, so these policies are the entire authorization model.
AI builders make this worse in a predictable way. They generate tables quickly, sometimes through SQL where RLS is not switched on automatically, and when a policy blocks a query during development, the fastest fix is a broader policy. The app keeps working and the hole stays open.
What teams often get wrong
The core misunderstanding is treating RLS as a switch rather than a design. Enabling it is necessary, but the policies decide what is actually protected.
RLS disabled on an exposed table
Every table in a schema exposed through the API needs RLS enabled. Supabase's dashboard security advisor flags tables where it is missing. Treat each warning as a finding, including tables that seem harmless, such as logs or lookup data that turn out to contain email addresses.
Policies that allow everything
A policy that uses a condition equivalent to true for select, especially for the anonymous role, publishes the whole table. That can be correct for genuinely public data, such as a product catalog, and wrong for anything else. Review every permissive policy and write down why it exists.
Signed in is not the same as authorized
A policy scoped only to the authenticated role lets every signed-in user read every row. When sign-up is open to the public, authenticated effectively means anyone with an email address. Policies for user data need an ownership condition, such as the row's owner column matching the current user's ID, or a tenancy condition through a membership table.
Missing checks on writes
Insert and update policies need a WITH CHECK condition that validates the new row. Without it, a user may be able to insert a row owned by someone else, or update the owner column on their own row to move it elsewhere. For updates, the USING condition controls which existing rows can be targeted and WITH CHECK controls what they can become; both matter.
Authorization from user-editable metadata
Supabase Auth stores user metadata that the user can update from the client. Deciding roles, plans or tenant membership from that metadata lets users grant themselves access. Use server-controlled app metadata, or a roles table that only privileged code can write.
Service-role keys in the client
The service-role key bypasses RLS entirely. It belongs only in server environments you control. If it appears in a variable with a public prefix, in client code or in a mobile app bundle, every policy is irrelevant. Agents and automations built on vibe-coded backends often run with this key too, which is one reason scoped identities matter, as covered in the limits of building agents only from vibe-code tools.
Views and functions that bypass policies
Views in Postgres run with the privileges of their owner by default, so a view over a protected table can expose rows the policies would hide. On recent Postgres versions you can create views with security invoker semantics so they respect the caller's permissions. Functions marked security definer also run with elevated privileges; if they live in an exposed schema, clients can call them. Check authorization inside such functions, set a fixed search path, or move them out of the exposed schema.
Storage left out
File storage has its own policies. A public bucket serves files to anyone with the URL, and missing policies on private buckets can allow listing or overwriting other users' files. Review storage alongside tables.
A practical approach to policy design
Design policies the way you would design an API. Start from deny: enable RLS on every exposed table, which with no policies means no access through the public API. Then add one policy per operation and role, each with an explicit reason.
- Select: owner or tenant member can read; public only for truly public data
- Insert: WITH CHECK that the owner column equals the current user and required fields are valid
- Update: USING for ownership of the existing row, WITH CHECK that ownership cannot change
- Delete: only where users should really delete, otherwise no policy
- Privileged operations: run on the server or in an edge function that checks authorization before using elevated credentials
For multi-tenant apps, put membership in its own table and use a small, carefully written helper function for the membership check. Policies on the membership table itself need particular care, because a policy that queries the same table it protects can recurse.
Implementation considerations
Keep schema and policies in version-controlled migrations, not only in the dashboard. Policies are security code and deserve the same review as any other code. Apply the same migrations to every environment, including preview branches and staging projects, so a policy fixed in one place is fixed everywhere.
Test policies directly. Supabase supports database tests with pgTAP, and you can also test from a client: one signed-out session with the public key, and two signed-in users who try to read, insert, update and delete each other's rows. Put these tests in continuous integration so a future generated migration cannot quietly widen access.
Watch performance. Policies run for every row a query touches. Supabase's guidance includes wrapping calls such as the current user ID function in a subselect so they are evaluated once per query, and indexing the columns policies filter on. Slow policies tempt teams to disable RLS, which is the wrong fix.
Check the rest of the surface area: storage buckets, realtime subscriptions, database functions exposed through the API, and any edge functions that use elevated keys.
Trade-offs
RLS lets the browser talk to the database safely without a custom backend, which keeps small apps simple. The cost is that authorization logic lives in SQL policies, which many teams read less fluently than application code.
Some teams choose the opposite: expose little or nothing through the public API and route all data access through their own server, using elevated credentials there with authorization in application code. That is more code to write, but it centralizes authorization in one familiar place. Either model can be secure; mixing them without a clear rule is where gaps appear.
Strict policies will break features that relied on broad access. That is expected, and it points directly at the queries that need fixing.
Lessons from ImadDhin work
The ImadDhin portal uses Firestore security rules rather than Supabase, so these are analogous code-level observations, not Supabase-specific findings.
The portal's rules deny by default with no catch-all rule. Collections that only server routes should touch, such as chat sessions, messages, saved memory and usage records, deny all client access, the equivalent of RLS enabled with no policies. Public intake collections allow create but not read, update or delete, with validation functions checking each new record's shape, which plays the role of WITH CHECK.
One pattern transfers directly, and it is a common finding in inspected codebases. When a server writes through the client SDK, a rule has to accept any create for the server to get in, and rules cannot distinguish a server using public credentials from a browser. The Supabase equivalent is a server route that uses the public key and therefore needs a permissive policy to work: that policy opens the table to everyone. The fix on both platforms is the same: use properly scoped server credentials, or validate every write in the policy.
Rule deployment is another shared trap. A Firebase project can hold more than one database, and rules must be deployed to each one or client reads can fail even when the rules file looks correct. The Supabase parallel is applying migrations to every project and branch, not just the one you tested.
Common mistakes to test for
- Query every exposed table with the public key while signed out
- As user B, select, update and delete user A's rows by ID
- Insert a row with another user's ID in the owner column
- Update your own row to change its owner or tenant
- Edit your own user metadata to claim an admin role
- Query views and call exposed functions as an ordinary user
- List and overwrite files in another user's storage path
- Search the client bundle for the service-role key
When a simpler solution is better
If your app has a few tables and a single owner per row, a handful of straightforward ownership policies is enough. Avoid clever helper functions and multi-tenant schemes until the product needs them.
If your team is more comfortable in application code than SQL, consider exposing nothing through the public API and serving data through your own server routes. Fewer exposed tables mean fewer policies to get right. For a prototype with no real users, keep it private until the policies are reviewed.
Get your policies reviewed
Enable RLS everywhere, write ownership-based policies with checks on writes, move elevated keys to the server, and test with a signed-out client and two users. If you want an experienced review of your Supabase project and the fixes applied, see ImadDhin's vibe-code rescue practice, send details through the project brief, or book a 30-minute call.
Frequently asked questions
Is the Supabase public key a secret?
No. It is designed to be used in the browser. Row-level security policies decide what it can access, which is why RLS must be enabled and correctly written on every exposed table.
Is enabling RLS enough?
Enabling RLS with no policies blocks access through the public API. The risk comes from the policies you add: ones that allow everything, check only that a user is signed in, or lack WITH CHECK on writes.
Where should the service-role key be used?
Only in server environments you control, such as your backend or edge functions that check authorization first. It bypasses RLS, so it must never appear in client code, mobile bundles or public environment variables.
Can we rely on user metadata for roles?
No. Users can update their own user metadata. Store roles and tenant membership in server-controlled app metadata or in a table only privileged code can write.
How do we test Supabase RLS policies?
Use database tests such as pgTAP and client tests with a signed-out session and two separate users trying to access each other's rows. Run them in continuous integration so new migrations cannot widen access unnoticed.
Close the gaps in your Supabase policies
Walk through your schema, policies and keys.
Book a 30-minute callRLS review and production hardening for AI-built apps.
Vibe-code rescueChoose fix an existing prototype.
Send a project briefKeep reading
Vibe coding security: issues we check in AI-generated apps
AI-generated apps can look finished while any signed-in user can read everyone's data. The specific security issues we check before an AI-built app goes in front of customers.
How to turn a Lovable/v0 prototype into a production application
AI builders ship demos fast. Production needs auth, payments, secrets hygiene, observability, and an architecture that survives real users — here is the hardening path I use.
The limits of building agents only from vibe-code tools — and what it costs you at scale
Vibe-coded agents demo brilliantly and stall in production. Here is the exact wall they hit, what has to be rebuilt, and how to keep the speed without paying for it twice.
Firebase vs Supabase for AI products
Firebase is strongest for mobile-first products that need realtime sync, push and a managed Google ecosystem. Supabase is strongest when your AI features lean on relational data, SQL and vector search in Postgres. Here is how to choose by workload.