All resources
Supabase10 min read

Design Supabase Row Level Security as a data boundary

A practical guide to policies, roles, ownership checks, and tests that keep a client-facing Postgres database safe.

A practical PingFlow guide for developers working at the boundary between systems.

At a glance

Key takeaways

  • RLS belongs at the database boundary
  • Separate visibility from mutation
  • Use database identity, not client claims alone
In this guide

RLS belongs at the database boundary

Row Level Security, or RLS, makes Postgres evaluate whether a role can see or change each row. It is valuable in a Supabase application because browser and mobile clients may call the data API directly, and application code is not the only path to a table. Enable RLS on every table that can be reached by an untrusted role, then write policies that describe the business boundary. A hidden route or a UI check is not a substitute for a database policy.

Think in terms of the authenticated database role and the claims available in the request context. The anonymous role should see only intentionally public data. The authenticated role may see rows owned by the current user, rows shared with a team, or rows visible through a product plan. Service-role credentials bypass RLS, so keep them on trusted servers and never expose them in a client bundle.

Separate visibility from mutation

A policy for SELECT controls which rows a role can read. INSERT needs a WITH CHECK expression that validates the proposed row. UPDATE normally needs both a USING expression for the existing row and a WITH CHECK expression for the new row. DELETE uses USING to decide which existing rows can be removed. Reusing one broad expression for every command can allow a user to move a row into another owner's scope or delete data they should only be able to view.

Write policies around stable ownership fields and relationships. A user_id column can support a simple owner check. Team membership may require an EXISTS query against a membership table, and that query must itself be safe and indexed. Avoid policy expressions that depend on mutable client-provided fields such as role or plan unless those values are checked against an authoritative table.

Use database identity, not client claims alone

Supabase exposes the authenticated user's identity in request context, but an authorization decision often needs more than a user ID. A client can send a plan field, team ID, or owner ID in a row. Policies should compare those values with trusted relationships in the database. If a user belongs to a team, check a membership record with the appropriate status. If a feature is limited to Pro, derive the entitlement from a server-maintained subscription table rather than trusting a value in the request body.

Keep authorization data normalized enough to audit. A subscription status, entitlement version, or membership role should have an owner and an update path. If a policy calls a helper function, mark the function's security behavior deliberately and restrict who can execute or replace it. Avoid dynamic SQL based on user input. A policy is code that runs for every row, so clarity and least privilege matter.

Plan for service-role operations

Some trusted workflows need to update rows across users, such as a payment webhook applying a subscription change or a background job expiring an event. Run those operations on a server with a service-role key or a narrowly scoped database role, and validate the incoming event before the write. Do not create a generic admin endpoint that accepts arbitrary table names or filters. The server should expose a small command with explicit input and authorization.

Log service-role actions with an internal actor, event ID, affected resource, and outcome. Redact secrets and customer content. If a service-role operation writes a row that client policies later expose, validate the resulting shape before committing. Bypassing RLS does not bypass business correctness. It only changes which database guard is responsible for enforcing it.

Avoid policy recursion and accidental exposure

A policy that queries another table can trigger that table's policies and create recursion or an unexpected denial. Design membership lookups with a safe, minimal helper and test the effective role behavior. Be cautious with views and functions that run with elevated privileges. A view can expose columns or rows that the underlying table would not, and a security-definer function can become an escalation path if its search path and inputs are not controlled.

Select only the columns a client needs. RLS filters rows, not columns, so a user who can read a row may see every field returned by the query. Keep private notes, provider identifiers, and internal audit fields in separate tables or expose a safe view. Add constraints for ownership and foreign keys so a policy is not the only protection against inconsistent references.

Test as every relevant role

RLS tests should run as anonymous, authenticated owner, authenticated non-owner, team member, removed member, and trusted server roles. Test SELECT, INSERT, UPDATE, and DELETE separately. Include attempts to change an owner ID, move a row between teams, insert a row with another user's identity, and access a deleted or suspended account. Test empty results intentionally; an RLS denial often appears as no rows rather than a visible permission error.

Use a disposable database or a controlled test schema and apply the same migrations used in deployment. Test the client query shape, not only a direct SQL statement, because joins, views, and filters can change the effective result. Add a regression test for every authorization bug. A policy that is correct for one role can still leak through a second table or a service endpoint.

Keep policies reviewable and observable

Name policies by table, command, and intent, such as users can read own projects or members can update team documents. Keep migrations small and explain the business rule in a comment or adjacent documentation. Review indexes for policy lookup columns because a correct policy that scans a large membership table can become a denial-of-service risk under load.

When access appears wrong, capture the role, user ID, table, command, and safe query shape without logging private data. Inspect the policy definition and the membership state at the same time. RLS is strongest when it is treated as a first-class data boundary: explicit, tested, least-privileged, and tied to the product's real ownership model.

Test both the allowed and denied paths

RLS coverage should include an anonymous request, a normal authenticated user, a second user, and a privileged service path where one is intentionally required. Assert that inserts cannot forge ownership, updates cannot move a row across tenants, and deletes cannot bypass soft-delete rules. Run these checks after policy or schema changes with a disposable dataset. The strongest policy is one whose denial behavior is continuously demonstrated, not merely reviewed in SQL.

Implementation example

Write the tenant or ownership invariant in SQL and enforce it for select, insert, update, and delete independently. Keep service-role clients on trusted servers because they bypass RLS. Test policies with the same JWT claims and roles that the browser or mobile client will use, not only with an administrator connection.

sql
create policy "read own rows" on public.notes
for select to authenticated
using (owner_id = auth.uid());

Verify and troubleshoot

Run an anonymous request, two different authenticated users, a team member, and a service-role operation against each CRUD path. Assert that a user cannot forge ownership on insert, move a row across tenants, infer another user's existence through errors, or bypass soft-delete rules. Inspect the generated SQL or policy metadata when a denial surprises you.

Operations and recovery

Treat policy changes as security releases: review them, run disposable-dataset tests, and record the claim assumptions. Monitor denied requests for abuse and accidental client regressions. If a policy is too strict, fix the claim or data model rather than granting broad public access. Keep a service-role break-glass path audited and short-lived.

References and further reading

Use Supabase RLS and Auth JWT documentation, PostgreSQL CREATE POLICY semantics, and the OWASP Authorization Cheat Sheet. Keep policy tests beside migrations so a schema change cannot silently remove a boundary.