There is a file in our repo called rls-policies.sql. It enables row level security on ten tables and creates thirty-two policies. It is careful work: every policy is preceded by a DROP POLICY IF EXISTS so the whole script is idempotent and can be re-run against the database without a migration, and the ownership predicates are consistent all the way down.
Not one of those policies evaluates when a host loads their dashboard.
This is not a bug, and it took me a while to be able to say why without hedging. It is worth writing down because the Supabase-shaped confusion underneath it is extremely common, and the conclusion is not "RLS was a waste of time". The conclusion is that we had two security models and only knew about one of them.
Two doors into the same database
Supabase gives you a Postgres database and, in front of it, an auto-generated REST API over everything in the public schema. That API is the thing RLS was built for. A browser holds the anon key, and the only reason it cannot read your users table is the policies you wrote.
We use the first thing and not the second. Our data access is Drizzle over postgres-js on DATABASE_URL:
export const db = drizzle(client);
That is an ordinary Postgres connection as the database owner. auth.uid() in that session is null, because auth.uid() reads a JWT claim out of the request-scoped settings that PostgREST populates, and there is no PostgREST in this path. More to the point, the owner role is not subject to RLS at all. Every policy in that file is skipped before the question of what auth.uid() returns can come up.
The Supabase client does appear in our codebase, but only for authentication. I checked rather than assumed, because the claim is load bearing: there is not a single supabase.from(...) call in the app. createServerClient exists to read cookies, refresh sessions and tell us who the user is. Once we know that, every row we read or write goes through Drizzle.
So the authorisation that actually protects a host's data is plain TypeScript in our own queries. When the dashboard lists venues, the WHERE owner_user_id = ... is in the query we wrote. If somebody deletes that clause, no policy catches it. The database will cheerfully hand over every venue belonging to everybody.
Then why keep the policies
Because the second door exists whether or not we walk through it.
The anon key is a public value. Ours is in the client bundle, under a NEXT_PUBLIC_ name, which is correct and intended: that is what the key is for. It is also enough to talk to the auto-generated API. A Supabase project with RLS switched off on a table in public is a table that anyone who views source can read.
That is the whole argument. RLS here is not protecting us from our own application. It is protecting us from a surface we never use, cannot easily remove, and would otherwise be publishing. Turning it on costs nothing at runtime for us, precisely because our queries never reach it.
It also means the policies are doing their job in the only way we will never observe. There is no request path in our app that would get slower or fail if a policy were subtly wrong. The feedback loop is empty. Which brings me to the uncomfortable half of this post.
The policies were written for a product we do not have
Read this one out of context and it looks fine:
CREATE POLICY leaderboard_entries_select ON public.leaderboard_entries
FOR SELECT
USING (
auth.role() = 'authenticated'
);
Any authenticated user may read any leaderboard entry. There are several in this shape, on answers and table_participants too, with comments like "needed for leaderboard display".
That comment describes an architecture where the player's phone is an authenticated Supabase client subscribing to the leaderboard table directly. It is a completely reasonable way to build a realtime quiz, and it is not how ours works. Our players never authenticate. They scan a code, type a team name, and talk to a WebSocket server; they do not have Supabase sessions, so auth.role() = 'authenticated' is false for every single one of them. The policy that was written to let players read the leaderboard does not apply to any player.
Who it does apply to is the only group that can be authenticated: account holders. Other hosts. So on the door we do not use, a signed-in host could read leaderboard rows from somebody else's quiz.
Same story with joining:
CREATE POLICY table_participants_insert ON public.table_participants
FOR INSERT
WITH CHECK (
session_id IN (
SELECT id FROM public.game_sessions
WHERE status IN ('setup', 'active', 'paused')
)
);
No predicate on who is inserting, only on the session being open. As permissive as it looks. It is survivable because joining is server-mediated in our app, with its own checks on late joins, name collisions and table limits, but if the intent is defence in depth then this particular depth is shallower than the file's structure suggests.
The honest summary is that the ownership policies, the ones keyed on owner_user_id, host_user_id and created_by, are tight and correct, and the ones that exist to let players read things are modelling a client that does not exist. They are not currently exploitable, because nothing authenticated ever reads through PostgREST. They are also the policies most likely to become exploitable the day somebody adds the first Supabase realtime subscription without re-reading this file.
A small detail that explains every cast
Every policy compares against auth.uid()::text:
USING (owner_user_id = auth.uid()::text)
auth.uid() returns a uuid. Our user id columns are text:
export const usersTable = pgTable('users_table', {
id: text('id').primaryKey(),
// ...
});
Thirty-two casts because of one column type chosen early. Not a problem, just the sort of thing that reads as an oversight until you find the schema. Worth a comment at the top of a policy file, which is now there.
What I would tell someone starting this
Decide, explicitly and in writing, which of the two models is load bearing.
If the browser talks to Supabase directly, RLS is your authorisation layer and it deserves the same scrutiny as auth code: tests, review, the lot. If a server with a direct connection talks to Postgres, RLS is a backstop on an API surface you are not using, and your real authorisation is the WHERE clause in every query. Both are fine. Believing you have the first while you have built the second is how a dashboard ends up protected by a policy file that never executes.
Ours is the second, and the ownership model it enforces in TypeScript is the one you can see in the product: a venue belongs to an account, and on the top plan up to five hosts can be invited to it by email and run their own quizzes under one subscription. Every one of those invited hosts is an authenticated user, which is exactly why "any authenticated user" was the wrong predicate to leave lying around in a policy file, even an unused one.
The commitments we make about all of this, what is kept, for how long, and who can see it, are in the privacy policy, and they are enforced by application code. The policy file is the belt on a door nobody opens. Useful. Just not the thing standing between one pub's quiz and another's.
Top comments (1)
The two-security-models framing is exactly right, and there is a durable version of your conclusion that keeps
rls-policies.sqlload-bearing instead of decorative. The owner role bypasses RLS — which is why none of the 32 policies run on the Drizzle path — so the fix is not to delete them, it is to stop connecting as the owner.app_backend) that is not the table owner, grant it only the DML it needs, and connect Drizzle as that role.alter table ... force row level security, so even the owner stops bypassing (defense against the next person who points the pooler at the owner).set local app.user_id = $1, and write the policies againstcurrent_setting('app.user_id')instead ofauth.uid()— there is no PostgREST on this path to populate the JWT claim.Now a missing
whereclause is a zero-row bug, not a cross-tenant leak: the guarantee you wanted from the policies, enforced on the connection you actually use. Worth a test that a user-context query cannot read another user’s rows; I keep a two-account probe for exactly that shape: github.com/cekuu35/supabase-rls-le...