Row-level security is not enough

RLS is a real control. It is also one policy away from a mistake, and it only protects customers who share a database to begin with.

An admin approves every new account by hand. Nothing is created until then. We reply by email; no newsletter, no sequence.

app.salescrew.io/today
The daily working view

The short answer

  • Row-level security enforces access at the row level inside a shared table. It depends on every policy being written correctly, on every table, forever, with no structural fallback if one is not.
  • Postgres applies multiple RLS policies on the same table as OR conditions. One overly permissive policy anywhere on that table defeats every stricter policy beside it.
  • SalesCrew treats RLS as one layer, not the whole answer. Single-tenant architecture, one Supabase project per client, removes the shared-table exposure entirely. RLS still enforces the three-axis scope model inside each client's project.
  • The same scope model applies to human users and to MCP tokens alike. An agent's access is bounded by the same policies a person's is, not by a separate and possibly weaker rule set.

What row-level security actually promises

Row-level security is a Postgres feature that lets a database enforce, per row, who may see or change it. A policy is a condition, evaluated for each row, that decides whether a query can touch it. It is a useful tool. Without it, access control lives entirely in application code, where a missed check in one code path can expose data another code path correctly protected.

The promise is narrower than it sounds. RLS enforces whatever the policies say, correctly, and nothing more. It does not audit itself. It does not warn when a new table is added without a policy. It does not stop two policies from combining in a way nobody intended. It is a mechanism for enforcing a decision a person already made correctly. It is not a guarantee that the decision was correct.

Where the mechanism actually breaks

The failure mode worth understanding is how Postgres combines multiple policies on one table: as an OR. Suppose a table has two policies. One correctly restricts rows to a user's own records. A second, broader one was added later for some other purpose. Postgres allows a row through if either policy permits it. So one accidentally permissive policy, written with a condition that is true more often than intended, defeats every stricter policy on that table. Only one policy needs to say yes.

The other common gap is coverage. A table added after the original access model was designed, with no policy applied at all. Postgres does not require every table to have RLS enabled. A table with RLS off is not protected by the policies on other tables, however careful those are. Both failures share a root cause. RLS correctness means every policy on every table staying right, indefinitely, as the schema grows. That is a maintenance burden that scales with the database, not a one-time setup task.

None of this means RLS is not worth using. It means RLS is a control that needs to be checked, tested and re-audited as a system grows. It is not a switch that, once flipped, guarantees isolation forever.

What to add on top of RLS, not instead of it

SalesCrew's answer is not to rely on RLS as the only boundary between customers. Each client gets its own Supabase project, with its own Postgres database. There is no shared table for a permissive policy to expose. If a policy inside one client's project is wrong, the blast radius is that client's own data, not every customer on the platform. There is no other customer's data in that database to leak.

Inside each client's project, RLS still does real work. It enforces the three-axis scope model, channel, Upwork profile and area, that decides which records a user or token can see. That model applies the same way whether the caller is a person in the UI or an MCP token issued to an agent. An agent's access is never a separate, looser policy bolted on afterward. It uses the same RLS policies a human teammate with the same grants would.

The general lesson for any team on a shared database: treat RLS as necessary and not sufficient. Test access as the least-privileged role would see it, not as an admin who bypasses the policies under test. Re-check coverage every time a table is added, because a missing policy is invisible until someone queries the table the wrong way. And if the product's core promise depends on strict isolation between customers, ask whether a policy is the right layer to carry that promise alone, or whether the boundary belongs one level up, at the database itself.

Questions

Is row-level security still worth using if a database boundary already exists?
Yes. Inside a single client's own project, RLS is still what enforces the three-axis scope model, channel, Upwork profile and area, so a user or a token sees only the rows their grants cover. The database boundary and RLS solve different problems. They are not substitutes.
What actually causes an RLS policy to fail?
Most often, a policy that is too permissive by default (USING true, or a condition that is true more often than intended), a policy missing on a table added after the original set was written, or two policies on one table that combine in a way nobody tested. Postgres applies row-level security policies as OR conditions.
How would a team even notice an RLS gap before it causes a real problem?
Test access as the least-privileged role would see it, not as an admin. Re-audit every table's policies whenever a new feature adds a table or a column that should be scoped. An audit log that covers reads, not only writes, also shows what a gap actually exposed if one existed.