NEXINFINITY META · Blog
Custom SaaS

Row-Level Security Is Not Enough: Six Ways a Multi-Tenant SaaS Leaks

S. Veera Kumar 9 min read

Row-level security is the right foundation for a multi-tenant SaaS on Postgres, and it is not enough. It decides which rows a signed-in user may touch; it says nothing about which columns they may change, which privileges the database grants by default, what your helper functions do for a caller they were not written for, or what happens to a shipped app when you tighten the rules. In audits of our own multi-tenant products we found each of the six patterns below. They share one trait: every test passes for the right user, and the leak appears only for the wrong caller.

Key takeaways

  • Row-level security is the foundation, not the whole defence: columns, grants, function guards and shipped apps each need their own control.
  • One permissive true policy defeats every other policy on a table; make existence checks functions that return yes or no.
  • Privilege, money and entitlement columns need column grants, because a row policy cannot say this column may not change.
  • Keep membership tables read-only to clients and route all changes through functions, and write every owner-rights guard as is-not-true so NULL refuses.
  • Test as the real role with the real public key, and expect tightening a shared table to fail silently somewhere unless you search every repository for the columns.

Why these bugs survive testing

A multi-tenant system is tested the way it is used: sign in as an ordinary user, open an ordinary screen, see ordinary data. Nothing fails. The leak lives in what a different caller can do: someone signed in to another tenant, someone holding only the public key that ships inside every app and website, or a role added later that the helpers were not written for. If you only ever test as the person the feature was built for, you are testing the one path that cannot fail.

The discipline that finds all six is the same: test as the real role, with the real key, and ask what a hostile or merely different caller could do. Everything below is a case of that.

1. One permissive policy defeats all the others

In Postgres, permissive row-security policies on a table are combined with OR. That means a single policy that says anyone may select, written for a harmless reason, overrides every careful policy beside it. The classic version is a referral-code lookup: someone needs to check whether a code exists, so a policy allows reads to everyone, and the whole table is now readable by anybody holding the public key.

An existence check should never be a table read. Make it a function that runs with elevated rights and returns only a yes or no. And sweep for the pattern directly: list every policy whose condition is simply true, and justify each one in writing.

2. Row-level is not column-level

A policy such as a user may update the row where the id equals their own id decides which row they may rewrite. It says nothing about what they write into it. Combined with a table-wide update privilege, a signed-in user can set their own admin flag, their own credit balance or their own subscription tier. The with-check clause of a policy sees only the new row, so it cannot express this column may not change.

Treat three kinds of column as special: columns that confer privilege, columns that hold money, and columns that grant an entitlement. For those, take the table-wide update privilege away and grant update on the named, harmless columns only; or protect them with a trigger that compares old and new values. Column control is a grant, not a policy.

3. A membership table that members can write

Most multi-tenant products have a table that says who belongs to which organisation, with what role. A natural policy lets organisation admins manage it. The flaw is that a policy can check the role of the person acting, but cannot constrain the role value being written, so an admin can update their own row, or a colleague's, and make it owner. Whoever can write that table can write their own authority.

Make the membership table read-only to clients. All changes go through functions that enforce the role ladder and a last-owner guard on the server side. That is also a cleaner place to audit and to add rules, such as an organisation always keeping at least one owner.

4. Row security off means anyone can rewrite the table

On some managed Postgres platforms, new tables in the public schema are granted broad privileges to the anonymous and authenticated roles by default, and row security is the only thing holding those back. A table with row security off is therefore not merely readable by everyone; it can be modified, emptied or truncated by anyone holding the public key. Always check the actual grants before judging severity.

The fix is both halves together: turn row security on and revoke the unneeded grants. Row security alone leaves the privileges in place, so the day someone adds a permissive policy it is public again. Revoking alone leaves row security off, so a later grant reopens it. Together the table fails closed twice. And do not invent policies for callers that do not exist: if nothing calls the table through the auto-generated API, it needs row security on, grants revoked and no policies at all.

5. Functions that run with their owner's rights fail open on NULL

Functions that run with their owner's rights typically bypass the caller's row security, so they need their own guard. The natural guard is if not the caller is an admin then raise an error. It has a subtle flaw. For a caller who is not a member of the organisation, the helper that returns their role returns NULL, and in SQL a NULL tested against a list evaluates to NULL, and so does the negation of NULL, never true. An if on NULL does not fire, the error is skipped, and the function runs for anyone.

The same flaw hides in an or variant, and it shows up one layer away, too: a utility function that opens with a membership check returns NULL for a new kind of caller, and the NULL does not raise an error, it quietly empties things. We saw a parent-facing screen show an empty attendance register while the percentage on the next screen, which needed no date window, was correct. Two screens disagreeing about the same child is the tell-tale.

The rules are short. Write every guard as if the check is not true then raise, so NULL falls on the refusing side. Make boolean helpers return false instead of NULL. Separate helpers that answer a fact from wrappers that decide who may ask. And test the refusal as a real non-member; a guard that has never been seen to refuse has never been tested.

NULL does not raise. It empties things.

A half-right screen is far harder to notice than a blank one. When a new class of caller is added to an existing schema, grep every helper for membership predicates, not just the feature functions.

6. Fixing it can break everyone, quietly

Tightening a rule on a table that many apps share is a fleet-wide change, and the failure mode is silence. Through an auto-generated REST API, a permission failure on a read usually returns an empty list rather than an error, so an app does not crash: it says no account found. Nothing reaches the logs. We learned this when a correct security fix took username login down in several apps for hours, because the one caller that depended on an anonymous lookup lived in a different repository, in the shared login package.

Three habits prevent it. Test the real flow as the real role, not only that the app did not throw. Search every repository for the actual columns being tightened, not only the obvious consumers. And when a shipped binary has to keep working, narrow columns rather than rows, so the old version keeps functioning while the new rule applies.

A one-page audit you can run this week

None of this needs a security consultancy to start. It needs an afternoon and the discipline of testing as the wrong caller.

  • List every policy whose condition is simply true and justify each in writing.
  • List every table with row security off and read its grants for the anonymous and authenticated roles.
  • For every column that confers privilege, money or entitlement, confirm the table-wide update grant has been replaced with column grants.
  • Confirm the membership table is read-only to clients and changed only through functions with a last-owner guard.
  • Read every owner-rights function's first lines: the guard must be written as is-not-true and refuse a non-member.
  • Call the sensitive functions as a signed-in non-member, and call the tables with only the public key. Both should be refused.
What you seeLikely cause
Every user can read every row of a tableA permissive true policy, ORed with the careful ones
A user changed their own plan, balance or admin flagRow-level policy with a table-wide update grant
An organisation admin made themself ownerA membership table writable by clients
Anyone with the public key can empty a tableRow security off and default grants left in place
A function worked for a user who is not a memberA guard that fails open on NULL
One screen empty while its sibling is correctA helper returning NULL for a new kind of caller

Symptom to likely cause

Where this fits in a build

These are the checks we run on our own systems before a first customer logs in, and the reasons multi-tenant architecture is a decision to make early. For the compliance side, security and compliance for SaaS in India covers what the law expects. If you are planning a product, our custom SaaS development service includes this audit as part of the written scope, and you can send us the brief.

Frequently asked questions

Is row-level security enough to secure a multi-tenant SaaS?

No. It decides which rows a user may touch, but not which columns they may change, what privileges the database grants by default, how helper functions behave for unexpected callers, or what happens to shipped apps when you tighten rules. Use it as the foundation and add column grants, function guards and tests as the wrong caller.

Does Postgres row-level security protect individual columns?

No. Row security applies to rows. To stop a user changing a particular column, revoke the table-wide update privilege and grant update on specific columns, or use a trigger that compares old and new values.

What does a permissive row-security policy do?

Permissive policies on the same table and command are combined with OR, so a single policy allowing everything overrides all the others. A policy whose condition is simply true therefore exposes the whole table to every role it applies to.

Is it safe to ship the public anon key inside an app?

It is designed to be public, so it is safe only if every table is protected by row security and tight grants. Treat the key as something every attacker already has, and test your tables with only that key.

Why does a security guard in a database function fail open?

Because of three-valued logic. If a helper returns NULL for a non-member, then not NULL and NULL in a list are both NULL, so the if never fires and the error is skipped. Write the guard as is-not-true, and make boolean helpers return false instead of NULL.

Should clients be able to write the membership or roles table directly?

No. A policy can check the actor's role but cannot constrain the role being written, so an admin can promote themself. Make the table read-only to clients and change it only through server-side functions that enforce the role ladder and a last-owner guard.

Have a project in mind?

We design, build, and ship software end-to-end — with a fixed, written quote after a free scoping call.

More from the blog

Keep reading