Auth, Authorization & Multi-Tenancy's own domain taught that relying on application code alone to filter every query by tenant is fragile — one missed WHERE clause anywhere leaks data. Row Level Security is Postgres's own, real answer to exactly that fragility, and it's the single feature Supabase is built most deeply around.
3 min read
Auth & Multi-Tenancy's own domain already named the risk directly: a shared-schema, organization_id-filtered table is only as safe as every single query remembering to include that filter — miss it once, anywhere in the codebase, and that one mistake exposes cross-tenant data. Row Level Security (RLS) moves this enforcement out of application code entirely and into the database itself — once enabled on a table, Postgres refuses to return or modify any row that doesn't satisfy a defined policy, regardless of what the query itself says, with no way for application code to accidentally bypass it by forgetting a clause.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users can only see their own documents"
ON documents
FOR SELECT
USING (auth.uid() = owner_id);ENABLE ROW LEVEL SECURITY turns on enforcement for a table — critically, once enabled, a table with zero policies denies access to everyone, including the table's owner querying normally, which is a real, deliberate fail-closed default (Application Security's own defense-in-depth lesson named this exact failure-mode preference directly). A policy then defines the specific condition a row must satisfy — auth.uid() is Supabase's own function returning the currently-authenticated user's ID, making auth.uid() = owner_id a policy that's genuinely enforced at the database level for every single query, from every single code path, with no application-level filter to forget.
USING vs. WITH CHECK: read access and write access are separate concernsCREATE POLICY "users can only update their own documents"
ON documents
FOR UPDATE
USING (auth.uid() = owner_id) -- which EXISTING rows can be targeted
WITH CHECK (auth.uid() = owner_id); -- what the row looks like AFTER the updateUSING governs which existing rows a policy even allows touching; WITH CHECK governs whether the resulting row (after an insert or update) is still allowed. This separation matters for a real, concrete case: without a WITH CHECK clause, a user could potentially update their own row's owner_id to someone else's ID, satisfying USING's check against the row before the update while producing a row that violates the actual intent afterward — WITH CHECK closes exactly this gap.
CREATE POLICY "anyone can read published posts"
ON posts FOR SELECT
USING (status = 'published' OR auth.uid() = author_id);
CREATE POLICY "only the author can delete their post"
ON posts FOR DELETE
USING (auth.uid() = author_id);A table's real access rules are often genuinely different per operation — anyone might read a published post, but only its author can delete it — and RLS policies are written per-command (SELECT, INSERT, UPDATE, DELETE) precisely to express this, rather than forcing one single rule to cover every kind of access uniformly.
An RLS policy is checked by Postgres itself, for every single query, regardless of which application, which code path, which future developer, or even which direct psql connection touches the table — there is no code path that can accidentally skip it, because the enforcement doesn't live in application code at all. This is the single deepest reason Supabase's data model is genuinely safer by default than "a Postgres database plus careful application code," and it's exactly what Vercel's own domain pointed toward when naming RLS as something Vercel Postgres alone doesn't provide natively.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What problem does Row Level Security solve that relying on application code alone doesn't?
2. What happens to a table the moment RLS is enabled but before any policy is added?
3. What's the difference between a policy's USING clause and its WITH CHECK clause?
Supabase