Supabase’s own documentation already lists the row-level security (RLS) best practices correctly: enable RLS on every table in an exposed schema, write a policy that checks the caller instead of trusting the request, index the columns a policy filters on. None of that is wrong, and rewriting it here wouldn’t add anything. What the docs can’t tell you is which of those practices real apps actually skip, and I have a more direct way to find out than most people writing about this: across the 21 real vibe-coded apps in AxonBuild’s audit corpus, all audited in June and July 2026, row-level security gaps showed up in 9 of them, and none of those apps were missing the concept. Each one had RLS working somewhere. Just not everywhere it needed to.
The one best practice everything else depends on
Row-level security, RLS, is Postgres’s own row-by-row permission system, and in Supabase it’s the only thing standing between a table and anyone holding the project’s public key. If you take one thing from this list, take this: enable it on every table your project exposes through its API, and then prove it holds by trying the exact request a stranger would make. Don’t read the policy back to yourself and nod along. Make the request. The six practices below are what I keep finding broken underneath a policy that looked, on paper, like it did its job. Every one of them is checkable with a second account and a plain request; none needs new tooling.
Practice 1: enable RLS on every reachable table, including the one you added last week
The easiest table to leave exposed is the one that didn’t exist when you turned RLS on everywhere else. A schema built in one sitting gets audited in one sitting; a table added three weeks later, for a feature nobody thought to circle back on, doesn’t. In one food-delivery app I checked, that’s exactly what happened: a table added after the rest of the schema was built had simply never had RLS switched on, while the tables from the original build were fine. Supabase exposes every public table to whoever holds the project’s anon key, on purpose, so a table with no RLS and no policy is a table anyone can read the moment they know its name.
The fix is a habit, not a one-time audit: every time a migration adds a table to an exposed schema, RLS goes on before the table takes its first row, not after someone notices it’s missing. If you want to confirm a given table is actually protected rather than just present, one plain request on the anon key, no login and no favor from a teammate, settles it either way.
Practice 2: write the Supabase RLS policy against the owner column, not against true
with check (true) is the fastest way to make an insert error disappear, and it disappears because the check now says yes to everything, logged in or not. I’ve seen it land on a table that took public submissions, pasted in specifically to clear the exact error a missing policy throws. The error goes away. So does the protection.
-- Clears the insert error. Also clears the door.
create policy "insert_orders"
on orders
for insert
to authenticated
with check (true);
-- Ties the new row to whoever is actually inserting it.
create policy "insert_orders"
on orders
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
Postgres runs the expression exactly as written. It has no way to know you meant “the caller’s own row” when you wrote true, or to warn you that the two produce identical behavior for an attacker even though only one of them was intentional. If a policy like this is what sent you looking for this post in the first place, the six real causes behind that exact insert error get their own full treatment elsewhere; this list is the practices that stop you from writing the anti-pattern to begin with, not the debugging session after you already have.
Practice 3: match the policy’s role to whoever is actually calling
A policy can be syntactically perfect and still protect nothing. Supabase row-level security policies are scoped to a role with the to clause, and a policy that never matches the role making the actual request behaves exactly like no policy at all. More than once I’ve opened a policy that read fine on its own and found it scoped to authenticated, sitting on an endpoint the client calls before a session exists, still using the anon key. I’ve also seen the mirror image: a policy written assuming service_role, wired to code that only ever holds the public key. Both pass a read-through. Neither one does anything, because the role named in the policy and the role making the request never meet.
create policy "read_own_profile"
on profiles
for select
to authenticated
using ( (select auth.uid()) = id );
That policy is correct syntax and a correct check. It’s also worth nothing on a page that fetches the profile before login finishes, because the request never carries an authenticated session for to authenticated to match. This is the same trust boundary AI coding tools default to the wrong side of: a generator checks whether someone is logged in, almost never whether the role attached to this specific request is the one the policy expects.
A policy exists the moment it runs without error; it enforces only when a stranger’s session comes back empty-handed.
Practice 4: keep the service_role key server-side, always
Every Supabase RLS security practice above assumes the request is arriving on a key that RLS actually governs. The service_role key exists to skip row-level security entirely, for server code you control, and none of the three practices above matter once that key reaches a browser bundle or a public repository. That’s a bigger failure than a wrong policy, and it’s why a correct policy is never the whole story on its own. Treat this one as a hard line, not a best practice with exceptions.
Practice 5: index the columns your policies filter on
Every policy runs as a WHERE clause against each row Postgres evaluates, and an unindexed column behind it turns a fast query into a sequential scan the moment a table grows past a demo-sized handful of rows.
create index on profiles (org_id);
Supabase’s own performance guidance backs this with a real number: adding the missing index on a filtered column took one of their benchmark queries from 171 milliseconds down to under a tenth of a millisecond. Correctness comes first on this list on purpose; the deeper performance mechanics of RLS, including the connection-pool and query-plan side of the same tables, get their own dedicated treatment elsewhere.
Practice 6: test every policy with two real accounts, not a linter
A static check can confirm a policy exists and parses. It cannot tell you whether the role it names is the one calling, whether the column it checks is the one that actually holds ownership, or whether a second permissive policy quietly overrides the scoped one you wrote first. Only a live request from an account that isn’t yours answers that.
- 01 Create a second real account in your own app, not a second row in the same session
- 02 Log in as account A and confirm the read or write you expect to work actually works
- 03 Log in as account B and try the identical action against account A’s row, changing only the id
- 04 Try the same action signed out entirely, on the anon key with no session attached
- 05 Repeat for every table a new feature touched, including ones you didn’t mean to change
That’s the same shape as a review that logs in as two separate accounts and tries the cross-read by hand, just run by you instead of outsourced. A written procedure for doing this on every policy, run on a schedule rather than when something feels off, is worth having on its own; treat this checklist as the minimum version.
None of the practices above is a secret Supabase is withholding from its own documentation. What its documentation can’t show you is what RLS looks like right before it fails anyway, wrong in the exact ways this list names, once a second account went looking instead of a static read-through. This list is one piece of what makes a Supabase app safe to run with real users on it, and one input into whether an app is ready to launch at all, the way that whole question gets answered once every domain gets the same treatment. A counted version of how often each of these specific practices gets skipped across the full audited set belongs to its own post, not this one.
Common questions about Supabase RLS best practices
Is RLS enabled by default in Supabase?
Not for the tables that matter here. Supabase turns RLS on by default when a table is created with the dashboard’s Table Editor, and leaves it off when the table arrives through raw SQL or a migration, the way AI builders create theirs. A migration-created table that arrives with RLS off is open to the anon key for both reads and writes from the moment it exists, and the one added after the rest of the schema is the likeliest to get missed.
Does every table need a policy?
Every table your project exposes through its data API needs RLS enabled, and once it’s enabled, no role can touch it until a policy explicitly allows something. A table with RLS on and zero policies is locked to everyone, which is safer than the alternative while you write the real rule.
Should I ever use with check (true)?
Not on a table anyone outside your own server can reach. It clears the insert error by allowing every insert, which is the same access as having no policy at all, just harder to spot in a code review because a policy is technically there.
What does an RLS policy actually check?
Two different checks, not one. using decides whether an existing row is visible or touchable at all, and select, update, and delete all lean on it. with check decides whether a row being written is allowed to exist in the first place. An insert has nothing existing for using to evaluate until after it commits, which is why that half of the policy sits idle on an insert.
Do RLS policies slow down queries?
They can, if the column a policy filters on has no index, and the slowdown gets worse as the table grows rather than showing up in a demo account. That’s a real, fixable performance question, and it’s a separate one from whether the policy is correct; the query-latency side of Supabase RLS is covered on its own.
Don't guess where you stand.
The free 2-minute Beyond the Demo Scorecard estimates where you stand across 12 readiness areas and shows what to check first.