Supabase is built on Postgres, and row-level security (RLS) is a genuine Postgres feature, not a Supabase-specific abstraction. That's worth internalizing early, because it means the mental model you need is "who is allowed to see or touch this row," enforced at the database layer, independent of whatever your application code does or forgets to do.

What row-level security actually does

Without RLS enabled, any authenticated client with a valid API key can read or write any row in a table it has grants on, regardless of what your frontend code intends to show. RLS adds a policy layer evaluated by Postgres itself on every query, so even a client-side bug or a malicious request against your API can't bypass the rule — the database simply won't return or accept rows that don't match the policy.

This matters more with Supabase than with a traditional backend because Supabase encourages calling the database directly from the client. That's a productivity win, but it means your database policies are your actual security boundary, not a "nice to have" behind an application layer that used to do the real checking.

Start from default-deny

Enabling RLS on a table with no policies defined blocks all access by default, which is the correct starting point. We enable RLS on every table the moment it's created, before adding a single row of real data, so there's never a window where a table is accidentally wide open.

-- enable RLS immediately, before any policies exist alter table journal_entries enable row level security;

Four patterns we reuse constantly

Most application data fits one of a handful of ownership shapes. Here are the four we implement on nearly every project:

1. Strict owner-only access

Used for private data like journal entries in Ayori. A user can only see and modify rows where a user_id column matches their own authenticated ID.

create policy "owner can manage own entries" on journal_entries for all using (auth.uid() = user_id) with check (auth.uid() = user_id);

2. Shared team or workspace access

Used for Telynall's shared editorial workspaces. Access is granted through a join table mapping users to workspaces, rather than a direct column on the content table.

create policy "workspace members can view drafts" on drafts for select using ( exists ( select 1 from workspace_members where workspace_members.workspace_id = drafts.workspace_id and workspace_members.user_id = auth.uid() ) );

3. Public read, owner write

Used for Cricket Clash's public match links: anyone can view a live scorecard, but only the scoring device (or an authenticated team admin) can update it.

4. Role-gated access

Used in Consisly's client status pages, where a lightweight role stored in a profile table determines whether a user can see internal notes versus just the client-facing summary.

Ad Placement (Placeholder)

Testing policies like real code

Policies are logic, and logic needs tests. We write a small suite of policy tests using Supabase's local development environment: create two test users, attempt cross-user access for every table, and assert it fails. This catches the single most common RLS bug we see — a policy that works correctly for the "happy path" owner but was never tested against a second, unrelated user attempting the same query.

We run this suite in CI on every migration change. Policies are easy to get right in isolation and easy to accidentally break when a table gets a new column or a new join is introduced elsewhere in the schema.

Mistakes that pass a demo but fail an audit

  • Enabling RLS but forgetting a policy on UPDATE or DELETE. A table with only a SELECT policy silently blocks writes entirely, which fails loudly — but a table with only an INSERT policy can let anyone overwrite rows they don't own if UPDATE was never addressed.
  • Using auth.uid() in a policy that governs a public API key context. If a request is unauthenticated, auth.uid() returns null, and depending on how a policy is written, null comparisons can behave unexpectedly rather than simply failing closed.
  • Granting broad access through a Postgres function marked security definer without understanding that it runs with the function owner's privileges, potentially bypassing RLS entirely if not scoped carefully.
  • Trusting client-supplied IDs. A policy that checks user_id = auth.uid() is safe; application code that lets a client pass an arbitrary user_id on insert without that check is not.

Performance considerations

Policies run on every query, so a policy with an expensive subquery can slow down a table significantly at scale. Indexing the columns used in policy conditions (typically the foreign key used for ownership checks) is not optional past a small dataset — we've seen unindexed policy columns turn a 20ms query into a 400ms one once a table crosses a few hundred thousand rows.

Frequently Asked Questions

RLS should be your source of truth for data access, but application-level checks are still useful for user experience — showing the right error message before a request even reaches the database.

Well-indexed policies add negligible overhead. Unindexed policy conditions on large tables are the actual cause of most RLS-related slowdowns we've diagnosed.

The service-role key bypasses RLS by design, since it's meant for trusted server-side operations. It should never be exposed to a client application.


Related Posts