skip to content

What is row-level security in a relational database, and when would you enforce tenant filtering with database row policies instead of a WHERE clause in application queries?

level: middleimportance: must knowfreq 58%

answer

  1. Privileges = which table; policies = which rows
  2. Enable + no policy = deny all
  3. Permissive policies OR together
  4. Predicate must be indexable
  5. Backstop, not replacement for app filter

basics

~20 s

Row-level security attaches a boolean predicate to a table so a session can only see or modify rows the predicate accepts. The engine applies it to every statement, so a forgotten tenant filter in application code cannot leak another tenant's rows.

solid answer

~50 s

Table privileges answer "may this role touch this table"; they cannot say "only these rows". Row-level security (RLS) closes that gap: you enable it on a table and attach *policies* — SQL boolean expressions evaluated per row — and the engine folds them into every SELECT, INSERT, UPDATE and DELETE automatically. The classic multi-tenant form is `tenant_id = current_setting('app.tenant_id')`. I reach for it when one forgotten filter means a cross-tenant leak, or when things other than my application code touch the tables: BI tools, ETL jobs, support scripts, an exposed SQL-over-HTTP layer. It makes isolation a property of the *table* instead of a property of every query anyone ever writes. It does not replace the application-side filter — I keep both. The app filter keeps code and plans obvious; RLS is the backstop that turns a mistake into a bug instead of a breach. Big caveat: table owners and superusers bypass policies unless you explicitly force them.

code

sql · 7 lines
sql
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;

CREATE POLICY invoice_tenant_isolation ON invoice
  FOR ALL
  TO app_user
  USING      (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

go deeper

for a junior

Be able to say that policies filter rows automatically, that privileges only control table-level access, and that no policy means no rows.

for a middle

Explain the enable-plus-policy mechanics, permissive OR-ing, the fail-closed default, and why you keep the application filter too.

for a senior

Frame it as defence in depth across all access paths, name the bypass paths (owner, superuser, bypass attribute) and the indexability requirement, and describe testing as the non-exempt role.

for a principal

Position RLS within a tenancy strategy: what it guarantees, what it cannot guarantee against a compromised app tier, its plan and operational costs, and where you would instead separate schemas or databases.

## The gap it fills The classic privilege model is coarse. `GRANT SELECT ON invoice TO app_user` says the role may read the table — all of it. Column privileges narrow *which columns*, never *which rows*. Yet almost every application has row-scoped authorization: a tenant sees its own invoices, a clinician sees their own patients. Traditionally that lives in the application: every query carries `AND tenant_id = ?`. The rule is then only as strong as the least careful query ever written. Row-level security moves the rule into the table definition. You turn the feature on for a table and attach one or more **policies**. A policy is a named boolean SQL expression over the row's own columns plus session context (the current database role, a session variable holding a tenant id, a lookup into a membership table). Whenever any statement touches the table, the engine adds the applicable policy expressions to it. Rows failing the expression simply are not there: SELECT does not return them, UPDATE and DELETE do not match them. ## Shape of a policy Two steps. First enable RLS on the table. Second create policies. A policy names the commands it applies to (SELECT, INSERT, UPDATE, DELETE or ALL), the roles it applies to, a `USING` predicate (which existing rows are visible/touchable) and optionally a `WITH CHECK` predicate (which row values may be written). A critical default: **enabling RLS with no policy means deny-all**, not allow-all. Non-exempt roles see an empty table. That fail-closed default is deliberate; it is also the number-one "my table vanished" incident after a migration. Multiple policies for the same command are, by default, **permissive** — they are OR-ed together, so each additional policy *widens* access. Engines also offer restrictive policies that AND in, used to layer a mandatory condition (for example "and the row is not soft-deleted") on top of permissive ones. ## Why put it in the database 1. **Defence in depth.** A new endpoint, an ad-hoc report, an ORM query built without the tenant scope, or an SQL injection hole all fail closed instead of returning the whole table. 2. **It covers every access path.** Analysts with psql, a BI connection, an ETL job and a support engineer all get the same rule. Application-layer filtering only protects the application. 3. **It survives refactors.** The rule is one object in the schema, reviewed once, rather than a convention that must hold in thousands of call sites. 4. **It enables direct exposure.** Architectures that put an HTTP layer straight over SQL depend on RLS for authorization entirely. ## What it does not do - It is not a substitute for `GRANT`. The role still needs table privileges; RLS narrows what those privileges reach. - It does not hide column *values* — masking a card number is a different mechanism. - It is bypassed by superusers, by roles flagged to bypass it, and by the table owner unless you force RLS on the owner too. - It cannot protect you from an application that can set the tenant context to any value it likes; the context must be set by trusted code. ## Costs to weigh The predicate is evaluated per row, so it must be index-friendly — a policy on `tenant_id` is worthless for performance unless `tenant_id` leads the useful indexes. Policies that call subqueries or non-inlinable functions can turn into per-row work. Plans also become harder to reason about because the predicate is invisible in the SQL text. And tests prove nothing if they run as an exempt role: your test harness must connect as the same non-exempt role production uses. ## The pragmatic position Keep both layers. The application keeps its explicit tenant predicate — it documents intent and gives the planner the same qual up front — and RLS sits underneath as the enforcement that does not depend on anybody remembering.

  • You enable row-level security on a table and every query suddenly returns zero rows. What happened?
    Enabling RLS is fail-closed: with the feature on and no policy matching the current role and command, no row satisfies any policy, so the table reads as empty. Nothing was deleted. The fix is to create the policies (and grant them to the right roles) in the same migration that enables RLS, and to verify as the application role rather than as the owner.
  • If you already filter by tenant in every application query, what does adding row policies actually buy you?
    It removes the dependency on every future query being written correctly, including queries you did not write — reports, migrations, support scripts, third-party tools. It also converts an SQL injection or an ORM mistake from a full-table disclosure into an empty result. The cost is a per-row predicate and slightly less obvious plans, which is why most teams keep the application filter as well.

GRANT is the badge that opens the building; a row policy is the floor-by-floor lock inside it. Without the second lock, anyone who gets in the front door walks every floor.

saying these in an interview costs you the question

  • Calling RLS a replacement for GRANT/REVOKE rather than a narrowing on top of it
  • Thinking enabling RLS without policies leaves the table fully readable
  • Assuming policies also apply to the table owner and to superusers by default
  • Believing RLS hides column values, confusing it with masking
  • Writing a policy predicate on an unindexed column and expecting no performance change

context