Alle Artikel
20. September 2026

Supabase row level security, explained for multi-tenant apps

Row level security is Postgres's built in authorization system. Here is how grants, policies and auth.uid() combine to keep tenants apart in a Supabase app.

Row level security (RLS) is Postgres's built in authorization system. It's the standard way to keep tenants apart in a Supabase backed application. Instead of filtering rows in your API layer, you write a policy once, and Postgres turns it into a WHERE clause on every query that touches the table. As of the current (September 2026) Supabase documentation, the multi-tenant pattern comes down to one recurring shape: a tenant_id or user_id column on every shared table, checked against auth.uid() or a JWT claim, enforced by a policy per operation.

What it is and why it matters

Postgres enforces two sequential checks before a request touches data: grants, then policies. Grants decide whether a role, anon, authenticated or service_role, can perform an operation on a table at all. Policies decide which rows within that table the operation applies to. Set up careful policies but leave a grant unrevoked, and the table still hands the anon role an insert path. The two controls have to work together, not stand in for each other.

RLS policies come in four shapes, one per operation. SELECT and DELETE use a using clause to filter which existing rows are visible or deletable. INSERT uses a with check clause to validate that a new row conforms to the policy before it lands. UPDATE combines both: using checks the existing row, with check checks the result. Writing a separate policy per operation, rather than one blanket rule, is what lets a table allow a user to read a wider set of rows than they're permitted to modify.

Most policies compare a row's owner column to auth.uid(), the helper that returns the ID of the user making the request. Here's the pitfall: auth.uid() returns null for unauthenticated requests, so a policy written as auth.uid() = user_id can behave in ways that surprise the anon role. Supabase's documentation recommends making the null check explicit: auth.uid() IS NOT NULL AND auth.uid() = user_id. It costs one clause and removes an entire class of edge case.

How it works in practice

The core multi-tenant pattern is a tenant_id foreign key on every table that holds tenant data, paired with a policy per operation that restricts rows to the caller's own tenant. Supabase's own guidance for B2B SaaS is direct about where that enforcement belongs: "RLS enforces tenant isolation at the database layer. RBAC controls what each user role can access." That's a deliberate design choice, not an implementation detail. Put the isolation check in the database, and every access path, the generated REST API, a server action, a background job, inherits it automatically. Put it only in application code, and every new access path becomes a new place the check can be forgotten.

Service accounts, internal agents and scheduled jobs don't carry a Supabase Auth session, but the same pattern still applies. The tenant identifier just comes from a custom claim embedded in the JWT instead of auth.uid(). The policy shape doesn't change: match a column against an identifier, using using and with check as the operation requires.

Because Supabase is standard Postgres underneath, the surrounding schema isn't constrained by any proprietary access model. Teams keep using foreign keys, joins, triggers and JSONB columns exactly as they would on a self-hosted Postgres instance, and RLS layers on top rather than replacing normal relational design. That portability matters for teams weighing platform lock-in: the tenant isolation logic lives in ordinary SQL, not a framework-specific abstraction. It's the same reasoning behind auditing other platform-level defaults early, the instinct covered in our Next.js technical SEO audit checklist: the earlier a structural control gets verified, the cheaper it is to fix.

Tradeoffs and edge cases

RLS isn't the only layer, and treating it as one is a common mistake. Grants still matter: new tables created in the public schema get default SELECT, INSERT, UPDATE and DELETE privileges for standard roles, which is an exposure risk on its own. The documentation states it plainly: "Tables and views exposed through the Data API without RLS can be accessed by any role with matching grants." A table with RLS enabled but permissive grants fails the same way a table with default grants and no RLS does: unintended access. The fix is procedural. Apply both controls to every exposed table, and revoke what isn't needed, rather than reaching for a single toggle.

RLS also has a scope limit worth knowing before it causes an incident: it doesn't apply inside Postgres functions. A function called with EXECUTE runs with the privileges it was granted, not filtered row by row the way a direct table query would be. EXECUTE privileges on functions need their own deliberate scoping, granted only to the roles that actually need to call them, separate from whatever table-level policies exist. For anything RLS can't express, per-IP rate limits, custom API key checks, quota enforcement, a pre-request Postgres function is the documented pattern, layered alongside RLS rather than instead of it.

None of this changes the cost side much. A policy is a WHERE clause, and a well-indexed tenant_id or user_id column keeps that filter cheap at any table size worth worrying about. The real risk in multi-tenant Supabase apps isn't RLS overhead. It's a table that got exposed through the Data API before its policy was written. That's the kind of schema-wide review we do as part of platform infrastructure work: checking every exposed table against its grants and policies before it ships, not after.

Frequently asked questions

What is row level security in Supabase?

Row level security (RLS) is Postgres's built in authorization system. It attaches a policy to a table that Postgres turns into a WHERE clause on every query, so a role only ever sees or changes the rows the policy allows, no application code required.

How does RLS enforce multi-tenant isolation?

Each table gets a policy that compares a tenant_id or user_id column to auth.uid() or a custom JWT claim. A request only touches rows where that match holds, so one tenant's queries cannot return another tenant's rows even if the application code has a bug.

Do I still need to check permissions in my application code?

Not for row access, but RLS is only half the picture. Grants decide whether a role can touch a table or function at all, RLS decides which rows, and RLS does not apply inside functions, so EXECUTE privileges still need to be scoped deliberately.

What is the auth.uid() null check pitfall?

auth.uid() returns null for unauthenticated requests, so a policy written as auth.uid() = user_id can behave unexpectedly for the anon role. Supabase's documentation recommends writing it as auth.uid() IS NOT NULL AND auth.uid() = user_id to make the intent explicit.

Does enabling RLS slow down my Supabase queries?

RLS adds a WHERE clause that Postgres's planner evaluates like any other filter, so a well-indexed tenant_id or user_id column keeps the cost negligible. The bigger risk is forgetting to enable RLS at all, which is a security gap, not a performance one.