Supabase RLS best practices are written down correctly in Supabase’s own documentation: enable RLS on every table in an exposed schema, write a policy that checks the caller instead of trusting the request, and index the columns a policy filters on. None of that is wrong. What the docs cannot tell you is which of those practices real apps skip.
I have a more direct way to find out. During June and July 2026 I ran audits on 21 third-party apps, where row-level security gaps showed up in 9 of them. Those apps reached me by selection, not random draw, so the count is a tally for that group, never a rate that holds for all AI-built apps.
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.
Supabase RLS best practices: the rule all eight depend 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. That is how RLS works in Supabase: row level security is a Postgres feature, not a Supabase add-on, and every Supabase RLS best practice in this 2026 guide, explained below with its SQL and the docs page that states it, is a way of making that feature hold on every table.
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 notifications table added after the rest of the schema had simply never had RLS switched on. 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. A table that starts with RLS off because SQL created it is one of several insecure defaults on managed platforms.
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.
How to enable RLS in Supabase on a table that already exists
Enabling RLS on an existing Supabase table takes one statement, alter table public.orders enable row level security, or one click on the dashboard’s Database > Policies page. The statement is the easy part: with RLS on and no policy yet, no data is accessible through the API with a publishable key, so ship the policy in the same migration.
If you would rather stay in the browser, you can turn on RLS in Supabase without any SQL. The Supabase row level security docs give the dashboard route on their Securing your API page: open the Database > Policies page and select Enable RLS on the table. In the SQL Editor or a migration file, the same change for one table is:
alter table public.orders enable row level security;
To enable RLS in Supabase for all tables that still have it off in public, have Postgres write one statement per table, then read the list before you run any of it:
select format('alter table %I.%I enable row level security;', schemaname, tablename)
from pg_tables
where schemaname = 'public' and rowsecurity = false;
Postgres applies a default-deny policy to a table that has RLS on and no policies, which is why each statement belongs in the same migration as that table’s policy. RLS off does not by itself make a table public either: a client reaches it only when its schema is exposed and the calling role holds a grant on it, and on existing projects new public tables receive those grants for anon and authenticated by default. When you are done, re-run the detection query from the top of this practice. It must return no rows; keep that empty result with the migration.
An empty result proves RLS is on, not that one account is kept out of another’s rows; in the Production Hardening Sprint that second proof is deliverable 1.4, data isolation across every table, where we implement and test Row-Level Security or equivalent server-side scoping across all tables, with explicit rules for intentionally public data. Deliverable 1.4 is verified this way: run read and write tests as anonymous users, different roles, and separate tenants.
Enable automatic RLS: what the Supabase setting does
Automatic RLS in Supabase is an event trigger that switches RLS on for every new table created in the public schema. Turn it on: a table that starts default-deny fails closed, with an empty page, instead of open. It writes no policy and does nothing for tables that already exist.
The setting sits in the Security section of the new-project form: a checkbox labeled “Enable automatic RLS”, with the line “Create an event trigger that automatically enables Row Level Security on all new tables in the public schema.”, and it starts unchecked. On a project that already exists, the dashboard’s database Tables and Policies pages show a notice titled “Automatically enable Row Level Security (RLS) on new tables”, whose Set up trigger button installs the same rls_auto_enable function and ensure_rls trigger, so Supabase’s auto enable for RLS is plain Postgres you can read.
Without the trigger, a create table run in the SQL Editor with no matching enable statement brings up a warning in three lines: “This query creates tables without enabling Row Level Security”, “Clients using anon or authenticated keys may be able to access” these tables, and “Choose whether to enable Row Level Security before running this query.” With the trigger active, the editor stops warning about new public tables, because the trigger covers them. That narrow job is the whole meaning of the Enable automatic RLS option in Supabase, and it is why I would switch it on for every project: it closes the forgotten-table gap and changes nothing else.
Then automate the habit. Supabase’s RLS documentation points to a Postgres event trigger, printed on its event triggers page, that enables RLS on every new public 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 IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN
BEGIN
EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity);
RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
EXCEPTION
WHEN OTHERS THEN
RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
RAISE;
END;
ELSE
RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name;
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, and only in public. Tables that already exist still need their own alter table statement, 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. Pasted onto an ownership-bearing table only to clear an insert error, it lets callers 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 );
Those two blocks are the Supabase RLS policy examples in detail: the first passes any row, and the second checks the row’s user_id against auth.uid(), the id of the user making the request.
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 was your fix for an insert error, the causes behind the new row violates row-level security policy error run deeper than this one example.
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.
Of the Supabase multi-tenant row level security best practices, this is the one the rest build on, and the same membership rule applied to every table and every data path, not only Supabase policies, is multi-tenant data isolation across every table.
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: no applicable policy allows it, so a read returns no rows with no error and an insert fails with error 42501. That mismatch is usually an availability bug, not a data leak. The Supabase docs on Postgres roles name anon as the role the API uses when a user is not signed in and authenticated as the one it uses when a user is signed in, and those are the two roles an RLS policy’s to clause normally names.
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. Getting this boundary wrong is one of the reasons why AI coding tools ship security holes.
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
The service_role key bypasses row level security in Supabase, and the Supabase docs say so directly: secret keys and the legacy service_role key “provide full access to your project’s data, bypassing Row Level Security”, and never belong in a browser. A request made with either key runs as the service_role Postgres role, which carries the BYPASSRLS attribute.
Supabase now issues two kinds of key, and older projects still carry the legacy pair beside them. The publishable key (sb_publishable_...) is for anything you ship and runs as anon, or as authenticated once a user signs in; the secret key (sb_secret_...) is for backend code and runs as service_role. The legacy anon and service_role keys are long-lived JWTs that the API keys page lists as the legacy versions of the publishable and secret keys, both systems keep working side by side until you disable the legacy pair, and Supabase says it is deprecating the legacy keys by the end of 2026. Which of these keys may sit in a browser at all is its own question, answered under API keys exposed on the frontend.
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; where every other key and token belongs is the wider job of secrets management. A correct policy cannot protect a request that is intentionally authorized to bypass it.
Keeping the key out of the browser is not the whole job. The NVD record for CVE-2026-85693, published on September 4, 2026, describes a retrieval endpoint in Chatbot UI, an open-source AI chat app, that uses a service-role Supabase client, which bypasses row-level security, and fails to validate file ownership, so an authenticated user can retrieve indexed content chunks from other users’ private files by supplying their file ids. My conclusion, not the record’s: keeping the key in server code is where this practice starts, not where it ends, because a route that runs on the service role has to make the ownership check the policy would have made.
Files follow the same rule in the Supabase Storage docs: service keys entirely bypass RLS policies, so a server client holding a service role key skips every storage.objects policy you wrote. If a secret or service_role key has already shipped somewhere public, start with how to rotate API keys safely, then use what each Supabase credential gives whoever holds it to judge what the leaked key exposed.
Keeping administrative keys on the server is deliverable 2.3 of the Production Hardening Sprint, server-only administrative keys, where we verify service-role and administrative keys are used exclusively in protected server environments. Deliverable 2.3 is verified this way: check runtime configuration, built assets, and each code path using administrative credentials, as the server-only key check in the sprint scope lists it.
Why a service_role client can still get RLS errors
A service_role client still hits RLS errors when the request carries a signed-in user’s session. Supabase’s troubleshooting note explains that RLS is enforced based on the Authorization header, so a user’s JWT there makes Postgres run the query as authenticated, under that user’s policies, instead of bypassing them.
The symptom is a server client, created with the service key, that returns RLS errors or no data. Supabase’s troubleshooting note on service role clients names three common ways a user session takes the key’s place: an SSR client, which is designed to share the user session from cookies; server code that sets the Authorization header to a user’s token; and a service-role client that calls signUp or another auth function that returns a session. The fix it gives is a separate client for the service role, created with supabase-js directly and kept away from user sign-in, with admin.createUser() for creating users from it. The same note says adding service_role to a policy does nothing, because the service role never runs policies in the first place.
The header rule also works in your favor. An Edge Function that forwards the caller’s Authorization header on purpose keeps that user’s RLS in force, while one that builds its client from SUPABASE_SERVICE_ROLE_KEY does not. The Supabase docs for Edge Functions say the secret key dictionary and the legacy service role key both bypass RLS, and that neither belongs in a browser. The nuance paragraph in Practice 3 is the same rule seen from the policy side.
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 (signed-out caller) | 170 ms | under 0.1 ms |
The second row is the select wrapper the Supabase RLS performance docs recommend: write (select auth.uid()) instead of a bare auth.uid(), so the optimizer runs an initPlan that caches the result instead of calling the function on each row, which works only when the result does not change from row to row. Those are Supabase’s numbers on their own test tables, verified against their documentation on September 28, 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 speeds up only signed-out requests, which you get for free because you should be naming roles anyway (Practice 3). Correctness comes first on this list on purpose; Supabase RLS performance at the connection-pool and query-plan level is a topic of its own.
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, as part of a repeatable way to test Supabase RLS. 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 kind of policy an AI coding tool can write when you ask it to “add an admin role”, because user_metadata is a field the app’s own client code can write 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. The Supabase RLS docs describe the same split for auth.jwt(): a role read from app_metadata is one the user cannot set, and one read from user_metadata is one they can.
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. RLS also does not validate input, rate limit anything or supervise server code holding the service key, and whether RLS is enough by itself gets the full answer.
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. Either way, the Supabase RLS docs and their “Expose a view safely” documentation give one rule: views can bypass RLS unless they run as the caller, which on Postgres 15 and up is the security invoker setting, security_invoker = true.
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. Supabase Storage RLS is ordinary RLS on that one table, so every bucket needs policies of its own. 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;
That is what column level security means in Supabase: RLS decides which rows a role reaches, and column grants decide which columns it may write.
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. When the read check holds and the function carries its own checks (next paragraph), the triage note for the contact-form case can be written out as: false positive; the scanner flagged contact submissions RLS as broken because anon can’t insert, but that’s actually correct by design, since submissions go through the send-contact-email Edge Function using the service role, which bypasses RLS; browsers never insert directly, so no action is needed.
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.
How to store third-party OAuth tokens in Supabase: table schema and RLS
Third-party OAuth tokens belong in a table the browser can never reach: a private schema left out of the exposed-schema list, RLS enabled with no client policies, and every grant revoked from anon and authenticated. Only server code with a direct database connection, such as an Edge Function, reads or refreshes them.
After an OAuth sign-in, Supabase Auth returns a provider token, plus a provider refresh token when the provider sends one, but Supabase’s social login docs say provider tokens are intentionally not stored in your project’s database and that Supabase Auth does not manage refreshing the provider token for the user. Keeping and refreshing them is the app’s job, and the same page recommends sending the token to “a trusted and secure server you control.”
create schema if not exists private;
create table private.oauth_tokens (
user_id uuid not null references auth.users (id) on delete cascade,
provider text not null,
access_secret_id uuid not null, -- id returned by vault.create_secret()
refresh_secret_id uuid,
expires_at timestamptz,
scopes text[],
updated_at timestamptz not null default now(),
primary key (user_id, provider)
);
alter table private.oauth_tokens enable row level security;
revoke all on private.oauth_tokens from anon, authenticated;
Server code reads this table over a direct Postgres connection, never through supabase-js: Supabase’s Securing your API guide keeps internal tables in schemas that aren’t exposed and notes that its example private schema prevents Data API access to the records in it, and Supabase’s custom schemas guide shows that a custom schema reaches the Data API only after it is added to “Exposed schemas” in the API settings. Inside an Edge Function that connection is the default SUPABASE_DB_URL variable, which Supabase’s Edge Function secrets page describes as the URL to “connect directly to your database”. Do not add a public RPC function that hands the tokens back; Practice 7’s rule on helper functions in exposed schemas applies here too.
RLS stays on even though no client should reach the table, so that a later grant or a change to the exposed-schema list meets default-deny instead of every row. The two id columns hold Vault secret ids, never token values: vault.create_secret() returns the UUID of the new secret, and Supabase Vault’s docs warn that anyone with access to the vault.decrypted_secrets view has access to decrypted secrets, so only the server’s database role should reach it. One secret per user is not stated in Vault’s docs either way, so treat this layout as my pattern, not Supabase’s. A scanner that flags this table as RLS enabled with no policies is reporting a false positive when no client key can reach it, checked the same way as the contact-form table above.
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, which is now a Prisma ORM v6 page, 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, written for Prisma ORM 6, 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 same goes for Clerk: test a Supabase RLS policy on auth.uid() with two Clerk accounts before launch.
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. RLS is one row of a full production readiness checklist, and this list is one piece of the answer to whether Supabase is safe for an app with real users on it.
Supabase row level security docs: where each practice is written down
Supabase row level security docs are spread over nine official pages, not one. The main RLS guide carries the policy rules, and eight more pages each cover one part: API keys, securing the Data API, database roles, the service-role troubleshooting note, RLS performance, Storage access control, the Security Advisor and the data-security overview.
The table maps each of those Supabase row level security docs pages, as they read on September 28, 2026, to the practice on this page it backs; where the official documentation has moved a page, the link goes to its current address.
| Supabase docs page | Practice on this page | What it states |
|---|---|---|
| Row Level Security guide | Practices 1, 2 and 8 | Every table in an exposed schema needs RLS; using decides which existing rows a policy reaches and with check what a new or changed row may look like; views bypass RLS unless they are created with security_invoker = true on Postgres 15 and up |
| Securing your API | Practice 1 | A table is not reachable through the Data API unless a role has been granted privileges on it, and an exposed table without RLS is open to any role with matching grants |
| API keys | Practice 4 | Secret keys and the legacy service_role key skip every RLS policy and belong only in backend code |
| Postgres roles | Practices 3 and 4 | anon serves signed-out requests, authenticated signed-in ones, and service_role is the role the API uses to bypass Row Level Security |
| Why is my service role key client getting RLS errors | Practice 4 | RLS is enforced on the Authorization header, so a user session inside a service-role client brings the user’s policies back |
| RLS performance and best practices | Practice 5 | Index policy columns, wrap functions in select, and use security definer functions for queries on other tables, to bypass their RLS when possible |
| Storage access control | Practice 8 | Storage access comes from RLS policies on storage.objects, with paths read through storage.foldername() |
| Advisors | The pre-launch checklist | The Security Advisor’s checks include rls disabled in public, rls enabled no policy, permissive rls policy and anon security definer function executable |
| Securing your data | The overview | Access policies protect sensitive data so users only reach what they are allowed to see |
The Supabase RLS documentation is right on every rule; this page is about which rule real apps skip.
Common questions about Supabase row level security
Does the service_role key bypass Supabase RLS?
Yes. The official Supabase docs on API keys say secret keys and the legacy service role key provide full access to your project’s data, bypassing row level security, because both run as the service_role Postgres role, which has the BYPASSRLS attribute. Only the publishable key, or the legacy anon key, belongs in a browser.
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.
Is RLS row level security or role level security?
RLS stands for row level security: a policy decides which rows a request may read or change. Roles come in through the policy’s to clause, which names Postgres roles such as anon for signed-out requests and authenticated for signed-in ones, so every Supabase policy is a row rule scoped to the roles it names.
What is an RLS policy?
An RLS policy in Supabase is a SQL rule attached to one table that Postgres adds to every matching query for the roles it names. 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.
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.
Should I enable RLS on Supabase?
Yes, enable row level security on every table in an exposed schema, which for most projects means public, because Supabase’s RLS guide warns that a table in an exposed schema without RLS “is readable and writable by any role with a grant on it.” Tables created through the Supabase Dashboard already have it on, while tables made in the SQL Editor, by a migration or by another tool need it enabled. With RLS on and no policy, client requests are refused, so the failure is an empty page, not an open table.
Should I enable auto rls Supabase?
Yes. With automatic RLS on, every new table in public starts with RLS enabled, so a table added later fails closed instead of open. It does nothing for tables that already exist, which still need the alter table statement from Practice 1, and it writes no policy. On an existing project, the Set up trigger notice in the dashboard installs the same trigger.
Are your customers' records walled off from each other?
Row level security is the wall between each customer's records, and one table added late can leave a gap in it. During an audit, we walk through your tables and user roles with you and flag where the gaps usually hide. You get a clear answer on which tables to lock down first.
Book a free audit →
Free · 30 minutes · No code access needed