Supabase's pitch is that your client talks to the database directly: "you can use them directly from the browser (two-tier architecture)," the API docs say, and the docs' own connection table marks a frontend application on the Data API as "Requires Row Level Security." Anyone holding your publishable key can issue arbitrary queries against every exposed table. The server tier that used to hold your "can this user see this row" checks is gone, so those checks have to live where the query terminates: inside Postgres.
The constraint
In a three-tier app, authorization lives in middleware you control: load the object, check ownership, return 403. In a Supabase app the browser's request becomes a Postgres query under the anon or authenticated role, and there is no middleware. Whatever decides which rows come back has to run inside the database, per query, per row.
The options
Keep your own API. Hold the secret key server-side, expose your own endpoints, never let the client touch the Data API. Legitimate, and necessary for anything that must stay secret (paid API calls, heavy writes). But you have rebuilt the tier Supabase exists to remove, and service_role bypasses RLS entirely, so your rules live in code again anyway.
Policies on every exposed table. The intended model. Postgres evaluates a policy expression per query; Supabase maps the request's JWT to a role and puts the user's claims where the policy can read them.
Policies plus security definer functions. For checks that need to read other tables (team membership, org roles) without granting the client access to them. The escape hatch, with its own caveats below.
The mechanism
Two separate checks stack. Grants decide whether a role can run an operation on the table at all; a missing grant fails with 42501 before any policy runs. Policies decide which rows that operation applies to. The docs put it in one line: "Think of a policy as adding a WHERE clause to every query."
auth.uid() is how a policy knows who is asking. It is a stable SQL function that reads the JWT claims Supabase stashes in the request.jwt.claims setting. You can verify the whole behavior in a scratch Postgres 17 database with a stub:
-- what supabase's auth.uid() boils down to
create schema auth;
create function auth.uid() returns uuid language sql stable as
$$ select nullif(
coalesce(
current_setting('request.jwt.claim.sub', true),
(current_setting('request.jwt.claims', true)::jsonb ->> 'sub')),
'')::uuid $$;
create table todos (
id bigint generated always as identity primary key,
user_id uuid not null,
task text not null
);
alter table todos enable row level security;
grant select on todos to authenticated;
-- setup elided: 10 rows inserted across two user ids, the
-- postgres roles "authenticated" and "anon" created first
-- the same query, run as the client role:
set role authenticated;
set request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';
select count(*) from todos; -- 0 (no policy yet: default-deny)
reset role; -- back to table owner
-- add the docs' canonical policy:
create policy "Individuals can view their own todos."
on todos for select
to authenticated
using ( (select auth.uid()) = user_id );
set role authenticated; -- same client session
select count(*) from todos; -- 5
Writes split into two checks the docs name explicitly: using decides which existing rows the operation may touch, with check decides what the resulting row is allowed to look like, so a user cannot update their user_id to someone else's. Two gotchas live right in the reference: an update policy also needs a matching select policy or "the UPDATE operation will not work as expected," and auth.uid() returns null for unauthenticated requests, making auth.uid() = user_id silently false rather than an error.
Where it bites
Empty is not an error. With RLS enabled and no policy, Postgres applies a default-deny: zero rows for API roles, while the table owner still sees everything. That asymmetry is the classic support ticket: the query "works in the SQL editor" because the editor runs as postgres, which has bypassrls.
Grants were the surprise. Tables created before the 2026 grants change arrived with every privilege already granted to anon, authenticated, and service_role, and "adding policies doesn't remove them." Supabase opened the opt-out on April 28, 2026, made the stricter behavior the default for new projects starting May 30, and applied it to all existing projects on October 30, 2026: new tables now need an explicit grant before the Data API can see them. Tables that already exist keep their current grants either way, so on anything older, revoke the defaults; policies alone do not.
Policies are evaluated per row. The performance doc's own repro removes 100,000 rows by filter in a single scan. We ran the same comparison on Postgres 17.11, 100,000 rows, one policy variant at a time: using ( auth.uid() = user_id ) executed the count in 46.6 ms; using ( (select auth.uid()) = user_id ) executed it in 3.6 ms, about 13x faster, and the plan shows why: the wrapped call becomes an InitPlan, evaluated once per statement instead of once per row. Add indexes to that and Supabase's troubleshooting guide reports "improvement seen over 100x on large tables" (their phrasing), matching the mechanism: a policy filtering on an unindexed column turns every read into a sequential scan, and only the first column of a composite index counts.
The timeout is asymmetric too. Role statement timeouts are documented: 3s for anon, 8s for authenticated, none for postgres (still capped at 2 minutes by the global timeout). A policy that scans a big table can blow the 8s client budget while looking instant in the dashboard.
The JWT goes stale. Authorization claims like app_metadata only update when the user's token refreshes, and user_metadata is client-editable, so it must never carry authorization data. Both warnings are in the docs' own reference section.
Client filters still matter. The docs recommend adding .eq('user_id', userId) in the client even though the policy duplicates it: "Postgres can use the filter to construct a better query plan." The policy is the seatbelt, not the suspension.
The numbers
- 3s / 8s / none: statement timeouts for
anon,authenticated, andpostgresroles (the last capped at 2 minutes globally), per the timeouts doc. - 46.6 ms vs 3.6 ms: unwrapped vs
(select ...)-wrappedauth.uid()on a 100,000-row table,count(*), Postgres 17.11, single run on this machine. Both plans showRows Removed by Filter: 100000; only the wrapped one showsInitPlan. - October 30, 2026: the date the opt-in-grants change applies to all existing projects, per the changelog.
- End of 2026: Supabase is deprecating the
anonandservice_rolekeys in favor of publishable and secret keys, per the API keys doc. Secret keys still bypass RLS entirely.
Our position
If the Data API is part of the architecture, RLS is the authorization layer, not a feature to enable later: enable it on every table in an exposed schema, write one policy per operation, wrap every helper call in (select ...), index every column a policy filters on, and keep authorization data in app_metadata, never user_metadata. Keep the secret key server-side for the operations that genuinely need it; the moment a request carries a user token, policies apply again. RLS is not free, the cost is just moved into the query plan, where Rows Removed by Filter tells you whether you are paying it well.
The takeaway
boundary, not your api
older tables keep default grants to all three roles
default-deny, and the owner still sees all rows, which is
why the sql editor lies
row you may write; an update needs a select policy too
an initplan; index the filter column or every read scans
user_metadata is user-editable; app_metadata is safe but
stale until the token refreshes
server-side only
RLS is the kind of data-layer decision we make in week one of a custom software build. If you have a Supabase project where the security model grew organically, send us the schema.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
