07 Compare

Row-level security vs app-layer auth

I have shipped row-level security on several products, including the times I got it wrong. The argument for the database is simple, and the mistakes are specific.

Application-layer authorisation is enforced only on the paths that remember to call it. Every new endpoint, background job, admin tool, or one-off script is another chance to forget, and forgetting is silent. A row-level security policy lives in the database and applies no matter who asks. For anything multi-tenant, that difference is the whole argument.

The failure mode that decides it

App-layer auth fails by omission. Somebody adds an endpoint at 6pm, does not call the permission helper, and nothing complains. There is no error, no failing test, and no visible symptom — just an endpoint that returns everybody’s data to anyone who finds it.

Database-layer auth fails by commission. If a policy is wrong, queries return nothing or return an error, and you notice immediately because the feature is broken. A noisy failure is enormously better than a silent one when the failure mode is a data breach.

That asymmetry is why I default to the database and treat application checks as an optimisation on top.

Side by side

Row-level securityApp-layer checks
Enforced onEvery query, alwaysPaths that call the check
New endpoint forgets itStill protectedExposed, silently
Failure modeLoud (broken feature)Silent (leaked data)
Error messagesPoor — rows just vanishGood — you control them
DebuggingHarder; empty results look like no dataEasier
Where the rule livesOne placeWherever it was remembered

The honest cost of RLS is that column three: when a policy is wrong, rows silently do not appear, and “no results” looks identical to “no data.” That is a real debugging tax and it is worth paying.

The three mistakes I have actually made

1. Assuming a new table is protected. Creating a table and turning RLS on is not the same as securing it. Default grants can leave a table more reachable than you expect, and a table with RLS enabled but no policies behaves differently from what most people assume. The only check I trust now is empirical: query the table as an anonymous user and look at what comes back. Do it after every migration.

2. Forgetting that views can bypass policies. A view built on a secured table does not automatically inherit that table’s row-level security. So a carefully protected table can leak through a convenience view somebody added for a dashboard. Every view needs checking separately, and this one is easy to miss because the underlying table looks fine.

3. Granting too narrowly for the operation. An upsert needs insert permission even when it will almost always update. The policy looks right, the common path works, and the failure appears in production on the branch you tested least. Test the operation you actually call, not the one you think you call.

How to verify

Reading your own policies and nodding is not verification. It is the same cognitive move that produced the bug.

  1. Query every table as an anonymous user. Expect nothing. Investigate anything.
  2. Query as a user from tenant A and confirm you cannot see tenant B’s rows. Create a second tenant specifically for this.
  3. Check every view separately from its underlying tables.
  4. Exercise every operation — select, insert, update, upsert, delete — not just the read path.
  5. Automate it. A script that runs these checks after each migration is worth more than any amount of policy review.

One warning on that last point: a verification script that writes data to test a policy is not a dry run, and running it against production will do exactly what you told it to. Keep the destructive checks on a disposable database.

Where app-layer checks still earn their place

Not as the boundary, but as the interface.

RLS gives a user an empty list when they lack access, which is a terrible experience if the correct response was “you need to be an admin to see this.” Application checks let you return a real message and stop the request early.

They also catch bad requests before they reach the database, which is cheaper. The rule I use: the database decides what is allowed; the application explains why it was not. If the two ever disagree, the database is right and the application has a bug.

Performance

RLS adds a predicate to every query. The cost is real but almost always dominated by whether the columns your policies filter on are indexed.

If a policy filters on a workspace identifier, that column needs an index, and it needs to be an index the planner will actually use. This is the single highest-leverage thing to check if RLS “feels slow” — in my experience it is nearly always an index problem being blamed on the concept.

FAQ

Database or application?

Database, for anything multi-tenant. App checks are enforced only where someone remembered to call them, and forgetting is silent.

Is RLS slow?

It adds a predicate per query, but cost is dominated by whether policy columns are indexed. Check the index before blaming the concept.

What is the most common mistake?

Assuming a new table is protected. Verify by querying as an anonymous user rather than reading your policies.

Can I use both?

Yes. Database decides what is allowed; the application explains why not. Never let the app layer be the only barrier.

Related: Postgres row-level security, practically and Supabase vs Firebase.