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.
Here are the eight practices, in the order they matter:
- Enable RLS on every reachable table, including the one you added last week. The detection query and the auto-enable trigger.
- Write the policy against the owner column, not against
true. Whatwith check (true)actually allows, plus the multi-tenant version. - Match the policy role to the real caller,
anonorauthenticated, and handle the signed-out case. Why a correct policy can return an empty page. - Keep the
service_rolekey server-side, always. It bypasses every policy you wrote. - Index the columns your policies filter on. Supabase’s own measured before-and-after numbers.
- Test every policy with two real accounts, not a linter. The five-step test, plus permissive versus restrictive.
- Never let the caller supply their own role. auth.jwt(), user_metadata versus app_metadata, and the roles table.
- Remember that RLS stops at the table. Views, storage buckets, and column grants.
The one best practice everything else depends on
Row-level security, RLS, is Postgres’s row-by-row permission system. In Supabase it works together with Data API grants to decide what an anon or authenticated request can do. If you take one thing from this list, take this: enable RLS on every table your project exposes through its API, write the narrow policies the feature needs, and then prove they hold by trying the exact request a stranger would make. Don’t read the policy back to yourself and nod along. Make the request.
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. Tables in an exposed schema can be reached through Supabase’s Data API according to their database grants. Without RLS, there is no row-level owner check between an allowed API role and the table’s rows.
If your schema came out of Lovable, Base44, Bolt, or Claude Code, check this one first. Tables created in the dashboard’s Table Editor get RLS switched on by default. Tables created by SQL or a migration file do not, and a generated migration is SQL.
Start by asking Postgres which tables have no row-level check. This query lists every table in the public schema with RLS off:
select n.nspname as schema_name, c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r', 'p')
and n.nspname = 'public'
and c.relrowsecurity = false
order by 2;
A row here proves only that a public table has RLS off. It does not prove that a Data API role can reach the table. Check that public is in the project’s exposed-schema settings, then inspect table grants for anon, authenticated, and any other API role. A table is reachable only when the current API configuration exposes its schema and the calling role has the required grant. Run those checks before every launch, and after every migration that touched the schema.
Then automate the habit. Supabase’s RLS documentation publishes a Postgres event trigger that enables RLS on every table created after the trigger is installed:
create or replace function rls_auto_enable()
returns event_trigger
language plpgsql
security definer
set search_path = pg_catalog
as $$
declare
cmd record;
begin
for cmd in
select *
from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
and object_type in ('table', 'partitioned table')
loop
if cmd.schema_name = 'public' then
execute format('alter table if exists %s enable row level security', cmd.object_identity);
end if;
end loop;
end;
$$;
drop event trigger if exists ensure_rls;
create event trigger ensure_rls
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function rls_auto_enable();
Read the limit carefully: this only covers tables created after you install it. Tables that already exist still need RLS enabled by hand, which is what the detection query above is for. And enabling RLS is not the same as writing the policy the feature needs.
The habit still matters more than the trigger: 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 is missing. Confirm the grants and policies, then test every relevant operation with signed-out, owner, and non-owner requests. One anonymous read cannot prove that inserts, updates, deletes, or authenticated cross-account access are equally constrained.
Practice 2: write the Supabase RLS policy against the owner column, not against true
with check (true) is a valid policy expression with a very broad meaning: every row presented by a matching role passes the row check. It is not equivalent to having no policy. With RLS enabled and no applicable policy, Postgres denies the operation by default; a permissive with check (true) changes that denial into an allowed insert for the roles named by the policy. I’ve seen it pasted onto an ownership-bearing table solely to clear an insert error, which allowed callers to supply an arbitrary owner ID.
-- 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. A deliberately public, write-only intake table can have a broad insert policy if its grants, accepted columns, validation, and abuse controls make that safe. A row with a user_id, organization ID, role, balance, or entitlement normally needs the policy to derive or verify that authority instead. If a policy like this is what sent you looking for this post, the causes behind that exact insert error get their own full treatment elsewhere.
The multi-tenant version: check membership, not user_id
A user_id = auth.uid() policy is correct for personal data and wrong the moment a second person joins the same account. Two teammates in one workspace each own their own rows and can see nothing the other created, which reads as a broken app rather than as a policy bug. The fix is to make the tenant the unit of ownership and let a membership table decide who belongs to it.
create policy "read_org_orders"
on orders
for select
to authenticated
using (
org_id in (
select org_id from memberships
where user_id = (select auth.uid())
)
);
Now access follows the membership row, so adding or removing a teammate is a row change instead of a policy change. Roles inside the tenant (who can invite, who can delete) are a second check on top of this one, and Practice 7 covers where that role is allowed to come from.
Practice 3: match the policy role to the real caller
Supabase maps signed-out Data API requests to the anon Postgres role and signed-in requests to authenticated. The to clause limits which roles can use a policy. If a signed-out request reaches a table that only has an authenticated policy, RLS does not fall open: the request is denied or returns no rows because no applicable policy allows it. That mismatch is usually an availability bug, not a data leak.
The dangerous mirror image is privileged server access. Supabase service keys and Postgres roles with BYPASSRLS can bypass row policies when used as privileged server credentials. A browser must never receive them. There is an important implementation nuance in Supabase’s RLS documentation: Supabase adheres to the signed-in user’s RLS policy even when the client library was initialized with a service key. Test the credential and session combination your code actually sends rather than inferring it from the initialization line.
create policy "read_own_profile"
on profiles
for select
to authenticated
using ( (select auth.uid()) = id );
That policy is correct syntax and a correct ownership check. On a page that fetches the profile before login finishes, it should deny the signed-out request. The fix depends on intent: wait for the session, add a deliberately public projection, or add a narrow anon policy for fields that are truly public. Changing the policy to true merely to remove the empty result trades a visible availability failure for an authorization defect. This is the same trust boundary AI coding tools default to the wrong side of.
The null trap that makes this silent
auth.uid() returns null when no session is attached to the request. In SQL, null = id is not false and not an error, it is null, which the policy treats as “no match”. Nothing throws. The page just renders empty, which is why this gets diagnosed as a data-loading bug for an hour before anyone reads the policy. Write the null check explicitly when you want the intent visible:
create policy "read_own_profile"
on profiles
for select
to authenticated
using (
(select auth.uid()) is not null
and (select auth.uid()) = id
);
The silence is not specific to null. Three of the four operations fail quietly when the using clause filters the rows out, and only one of them tells you why:
| Operation | What a blocked request looks like |
|---|---|
select | Zero rows returned, no error |
update | Zero rows affected, no error, when using filters the rows out; an error (42501) only when the proposed row fails with check |
delete | Zero rows affected, no error |
insert | An error: new row violates row-level security policy (Postgres code 42501) |
That asymmetry is why insert problems get fixed the same day and read problems get fixed the same quarter. It also explains why a cross-account read leak survives so long: the app that is over-permissive never complains at all.
| Looks like a policy | Actually a policy |
|---|---|
| with check (true), pasted onto an ownership table | with check ((select auth.uid()) = user_id) |
| authenticated policy, but the feature runs before login | The feature waits for a session or uses an intentionally narrow public policy |
| RLS on every table you remember creating | RLS on every table the schema currently has |
A policy that compiles proves its syntax. A second account proves its boundary.
Practice 4: keep the service_role key server-side, always
Every Supabase RLS practice above assumes the request is not carrying a privileged credential that bypasses row policies. Service keys belong in trusted server code and must never reach a browser bundle, public repository, logs, or error responses. A correct policy cannot protect a request that is intentionally authorized to bypass it.
Practice 5: index the columns your policies filter on
RLS policies add predicates to queries. When those predicates filter on unindexed columns, Postgres may have to inspect far more rows than the request returns, and the cost becomes visible as the table grows. Index the columns used in policy conditions when the workload and query plan support it.
create index orders_user_id_idx on orders (user_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.
The index is the first of five changes Supabase measured on the same benchmark. Here is the summary, so you can see which ones are worth doing before you read anything longer:
| Change | Before | After |
|---|---|---|
| Index the column the policy filters on | 171 ms | under 0.1 ms |
Wrap auth.uid() in a select | 179 ms | 9 ms |
| Repeat the policy’s filter in the query itself | 171 ms | 9 ms |
| Rewrite a join so the row’s column is compared to a set | 9,000 ms | 20 ms |
Name the role in the policy’s to clause | 170 ms | under 0.1 ms |
Those are Supabase’s numbers on their own test tables, verified against their documentation on 5 August 2026, not a promise about yours. The point of the table is the ranking, not the milliseconds: an index and a select wrapper cover most of the damage, and the last row is free because you should be naming roles anyway (Practice 3). 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 policy quietly widens what the first one allows. Only a live request from an account that is not yours answers that.
Permissive and restrictive are not the same thing
Every policy is permissive unless you say otherwise. Permissive policies combine with OR, so a second one can only widen access, never narrow it. Adding a policy to tighten a table is therefore the exact opposite of what happens. A policy declared as restrictive combines with AND, so it can only narrow, and it applies on top of whatever the permissive policies allowed.
create policy "require_mfa_for_updates"
on profiles
as restrictive
for update
to authenticated
using ( (select auth.jwt() ->> 'aal') = 'aal2' );
Use restrictive for conditions that must hold on every request no matter which permissive rule let it through: a multi-factor requirement, a suspended-account flag, a tenant that has passed its retention date. Use permissive for the ordinary “who may reach this row” question.
- 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
Keep a written version of this test beside every policy change. These five steps are the minimum; multi-tenant apps should also test two users in different organizations and a user whose role has just changed.
Practice 7: never let the caller supply their own role
auth.uid() gets most of the attention, but the second Supabase helper is where the interesting failures live. auth.jwt() returns the whole token of the user making the request, including whatever sits in their raw_user_meta_data and raw_app_meta_data columns. Those arrive in the token as the user_metadata and app_metadata claims. Only one of them is safe to authorize with.
Supabase’s documentation is blunt about the difference: raw_user_meta_data can be updated by the user themselves through the auth API, so it is not a good place to store authorization data. raw_app_meta_data cannot be updated by the user, so it is. An admin check written against the first one is not a check. It is a form field.
-- Unsafe. user_metadata is writable by the user it describes,
-- so this policy lets the caller grant themselves admin.
create policy "admins_read_all_orders"
on orders
for select
to authenticated
using ( (select auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' );
This is the exact policy an AI coding tool writes when you ask it to “add an admin role”, because user_metadata is the field the signup call already writes to. It looks identical to the safe version at a glance, and both of them pass a linter.
-- Safer. The role lives in a table only your server writes to.
create policy "admins_read_all_orders"
on orders
for select
to authenticated
using (
exists (
select 1 from user_roles
where user_roles.user_id = (select auth.uid())
and user_roles.role = 'admin'
)
);
app_metadata is the lighter-weight version of the same idea, read as (select auth.jwt() -> 'app_metadata' ->> 'role'), and it costs no extra query. It has one catch Supabase names directly: a JWT is not always fresh, so removing someone’s role does not take effect until their token refreshes. A roles table is read live, which is why it is the better default for anything you might need to revoke in a hurry.
One footnote that matters more than its length. When a membership or roles lookup makes a policy recursive or slow, the standard fix is a security definer helper function, and the standard mistake is creating it in public. A function in an exposed schema is callable through the Data API, so a helper written to shortcut RLS becomes a way to ask the database questions directly. Put those functions in a schema that is not in the project’s exposed schema list.
Practice 8: RLS stops at the table, and your app has three other doors
Every practice above protects rows in one table. Three parts of a normal Supabase project sit outside that protection, and each has its own way of leaking.
Views run as their creator unless you say otherwise
Supabase documents this plainly: views bypass RLS by default, because they are usually created by the postgres user. Build a convenience view over a protected table and you have published an unprotected copy of it. On Postgres 15 and up, create the view so it runs as the caller instead:
create view public_orders
with (security_invoker = true)
as select id, status, created_at from orders;
On older versions there is no security_invoker, so the fallback is to revoke access to the view from anon and authenticated, or to put the view in a schema the API does not expose.
Storage buckets need their own policies
File uploads live in storage.objects, a real table with real RLS. A bucket with no policies is not protected by whatever you wrote for orders. Scope by bucket, then by folder, so every user writes only under their own prefix:
create policy "users upload to their own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'user-uploads' and
(storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);
There is a trap here worth knowing before it costs you an evening. Supabase’s own troubleshooting note on the 403 upload error explains that the storage API inserts the object and then reads the new row back to return its metadata. A missing select policy on storage.objects therefore produces new row violates row-level security policy even when the insert policy is perfectly correct.
RLS filters rows, grants filter columns
A user who legitimately owns their profile row can still update every column in it, including role, plan, credits, and is_admin, unless the grants say otherwise. RLS has no opinion about columns. Column grants do:
revoke update on profiles from authenticated;
grant update (display_name, avatar_url) on profiles to authenticated;
Any column carrying authority or money belongs on the server side of that line, written by a trusted route rather than by the row’s owner.
When a missing policy is correct: tables written by an Edge Function
Not every table without an insert policy is a bug. If writes to a table go through an Edge Function that uses the service role, the function is the only writer, and there is no anon or authenticated insert policy because none should exist. Contact form submissions are the usual example: the browser calls the function, the function validates and inserts, and the table itself is closed to client keys entirely.
Scanners and AI reviewers flag this as broken constantly, because from the outside “RLS enabled, no insert policy” is indistinguishable from a table nobody finished. One check separates the two: can any client-side key reach that table at all? With RLS on and no applicable policy, an anon or authenticated request is denied by default, so the answer should be no for reads as well as writes. If that holds, the finding is a false positive. If a select policy quietly lets the client read what the function wrote, the finding was real and it was just pointed at the wrong operation.
The second check is about the function, not the table. Code running on the service role bypasses every policy on this page, so whatever validation the policy would have done has to exist inside the function: who is allowed to call it, which fields it accepts, and which ones it refuses to take from the request body.
Using Supabase RLS with Drizzle, Prisma, and other clients
Every practice above assumes the request arrives through Supabase’s Data API, where the caller’s token decides whether the query runs as anon or authenticated. An ORM does not work that way. Drizzle and Prisma both talk to the database over the Postgres connection string, and in Prisma’s own Supabase guide the direct string signs in as the postgres user and both pooler strings as postgres.[project-ref], which is that same role addressed through the pooler. That is the part that catches people out: connecting Supabase and Prisma is a database connection, not an API request, and the two are governed by different things.
Postgres is explicit about what that means. Its row security documentation states that superusers and roles with the BYPASSRLS attribute always bypass the row security system, and that table owners normally bypass it as well, unless the owner opts in with alter table ... force row level security. Your migrations created those tables over that same connection, so the role your ORM uses owns them. The policies you wrote are still on the tables. They are simply not deciding anything about the queries your server sends.
There is a second half to the same problem. Supabase documents auth.uid() as returning the ID of the user making the request, and a pooled connection opened by your API server is not a request made by an end user. There is no session for it to read, so a policy written against auth.uid() has nothing to match even after the ownership question is settled.
So the practical answer to why use Prisma with Supabase, or Drizzle, is to use it for the work your own server owns, and to keep client-facing reads and writes on the Data API where the token does the deciding. If you do want policies enforced on the ORM connection, you have to pass the identity yourself. Prisma’s official client-extension example for row level security does exactly that: it calls set_config('app.current_company_id', <id>, TRUE) inside a transaction, so the value lives only for that transaction, and the policies read it back with current_setting. The same example says plainly that your application should connect as a user with limited permissions that do not allow bypassing RLS, and it carries one caveat worth reading before you adopt it: because the extension wraps every query in its own batch transaction, calling $transaction() explicitly may not behave the way you expect.
Drizzle takes the other route and keeps the policies themselves in the schema. Drizzle’s RLS documentation defines policies with pgPolicy as a parameter of pgTable, declares roles with pgRole, and offers pgTable.withRLS() for turning RLS on without adding a policy. The drizzle-orm/supabase import ships the Supabase roles already marked as existing, anonRole, authenticatedRole and serviceRole among them, plus authUid, which is the (select auth.uid()) form Practice 5 rewards. .link() attaches a policy to a table Supabase already created, such as realtime.messages. One detail to keep straight while reading examples: crudPolicy belongs to the drizzle-orm/neon import, not the Supabase one. And declaring policies in Drizzle does not change who is connecting. It is a better place to keep policies, next to the schema and under version control, but the ownership and identity questions above still apply to the queries Drizzle itself sends.
Swapping out the auth library has the same shape. Supabase’s third-party auth documentation names five providers with first-class support as of August 2026: Clerk, Firebase Auth, Auth0, AWS Cognito and WorkOS. Better Auth is not on that list. The requirements matter more than the list does. The provider has to sign its JWTs asymmetrically and include a kid header so Supabase can pick the verifying key, and the token has to carry a role claim, set to authenticated for signed-in users, or there is no Postgres role for the query to run as. Anything outside those requirements is an integration you are maintaining yourself, and a policy written against auth.uid() is the first thing to test on it with two accounts.
The pre-launch RLS checklist
- 01 RLS is enabled on every table in an exposed schema, including the one added last week
- 02 The pg_class detection query returns no rows for the public schema
- 03 Every policy checks an owner, tenant, or membership column, never with check (true)
- 04 Multi-tenant tables check membership rather than a single user_id
- 05 Every policy names its roles in the to clause, anon or authenticated
- 06 The signed-out case is handled, because auth.uid() is null and null never matches
- 07 No role is read from user_metadata; roles live in app_metadata or a roles table
- 08 Security definer helper functions live in a schema the API does not expose
- 09 Views over protected tables are created with security_invoker = true
- 10 Storage buckets have their own policies on storage.objects, scoped by bucket_id
- 11 Column grants limit who can write role, plan, balance, and status columns
- 12 Every policy was tested from a second real account and from a signed-out session
None of the practices above is a secret Supabase is withholding from its own documentation. What its documentation cannot show you is how RLS fails in a real app, in the exact ways this list names, and the way to see that is the two-account test in Practice 6, run once against every table the app exposes. This list is one piece of what makes a Supabase app safe to run with real users on it.
Common questions about Supabase RLS best practices
Does the service_role key bypass Supabase RLS?
Yes: code running on the service_role key bypasses every RLS policy, so the first Supabase RLS best practice is to keep that key server-side, always, and give browser code only the anon or authenticated role. Any Edge Function that uses the service role has to carry the checks a policy would have done: who may call it, which fields it accepts, and which it refuses.
What is RLS in Supabase?
Row Level Security is a Postgres feature that attaches a condition to every query against a table, so the database itself decides which rows a request can see or change. In Supabase that condition is called a policy, and it applies to Data API requests made with the anon and authenticated roles.
What a policy decides on each operation, how you write one, and the exact points where its authority stops are a longer subject with a dedicated treatment of their own. This page starts one step after it, on the practices real apps skip.
Is RLS enabled by default in Supabase?
Supabase enables RLS by default for tables created through the dashboard’s Table Editor. Tables created through raw SQL or migrations need an explicit alter table ... enable row level security. Whether a role can reach an RLS-disabled table also depends on database grants, but there is no row-level policy protecting allowed operations until RLS is enabled.
Can Supabase enable RLS automatically on new tables?
Partly, and it depends on how the table gets created. Table Editor tables get RLS on by default; SQL and migration tables do not. For the rest, Supabase publishes a Postgres event trigger that runs alter table ... enable row level security on every table created after the trigger is installed, which is as close to automatic as this gets. It does nothing for tables that already exist, and enabling RLS still leaves you to write the policy.
Does every table need a policy?
Every table your project exposes through its Data API needs RLS enabled. Once it is enabled, Data API requests through the anon and authenticated roles are denied unless a policy allows the operation. Table owners and roles with BYPASSRLS are separate privileged cases, which is why they must stay behind trusted server boundaries. For normal client access, a table with RLS on and zero policies is default-deny while you write the real rule.
Should I ever use with check (true)?
Only when allowing every inserted row from the named role is genuinely the intended rule. It can fit a carefully constrained public intake table. It is usually wrong for rows carrying ownership, tenant, role, balance, or entitlement fields. With RLS enabled, no applicable policy denies by default; with check (true) explicitly allows the insert for matching roles when their table grants also permit that operation.
What does an RLS policy actually check?
Two different expressions, not one. using controls which existing rows a policy makes visible for selection, update, or deletion. with check controls whether a new version of a row is allowed for insert or update. An insert policy uses with check; an update commonly needs both the old row to pass using and the proposed row to pass with check. If an applicable policy omits with check, Postgres can reuse its using expression, depending on the policy command.
Does an update policy need a select policy too?
Often, yes. Postgres applies select policies whenever the statement has to read a row, and any request that returns the changed row back to the client needs one as well. Supabase documents this for storage uploads: an insert can fail with new row violates row-level security policy purely because no select policy lets the caller read back the row it just created. If an update or insert fails for no visible reason, check the select policy before you touch the write policy.
Do RLS policies slow down queries?
They can. An unindexed policy predicate can force Postgres to inspect many rows, and performance depends on table size, selectivity, the query’s own filters, and the plan Postgres chooses. That is a real, fixable performance question, separate from whether the policy is correct; the query-latency side of Supabase RLS is covered on its own.
Is RLS enough on its own?
No. RLS decides which rows a database request may touch, and that is all it does. It does not validate input, rate limit anything, filter columns, protect storage buckets you never wrote policies for, or supervise server code holding the service key. Those are separate controls, and whether RLS is enough by itself gets the full answer.
Built it with AI. Can’t get the last part right?
That’s the normal state of an AI-built app, and it’s fixable. I trace what the app actually does, explain what needs changing, and build it if you want me to.
Talk about your app →
Free 20-minute video call with me.