What Is Row-Level Security (RLS)? Database Access Control
Row-level security lets a database restrict which rows a query can see or modify, per user, enforced at the engine — not the application layer.
Row-level security (RLS) is a database feature that restricts which rows a query can read or modify based on who’s running it, enforced inside the database engine rather than in application code. Instead of every query filtering by WHERE tenant_id = ? and trusting the application to get it right every time, the database itself refuses to return or touch rows a policy doesn’t allow — even if the query never mentions the restriction at all.
The problem it solves
Most multi-tenant or multi-user applications need to make sure one user’s queries can’t see another user’s data. The conventional approach bakes that check into every query: application code adds a WHERE clause, an ORM scopes queries automatically, or a middle tier filters results before returning them. This works until it doesn’t — a forgotten filter in one endpoint, a raw query written for a one-off script, or a background job that skips the application layer entirely, and rows leak across tenants.
RLS moves the check to the last line of defense: the database. A policy is attached to a table, and the engine applies it to every query against that table, regardless of which application, script, or admin tool issued it. If the policy says a row belongs to tenant_id = current_tenant(), no query — written correctly or not — can return a row outside that scope.
How it works
RLS is defined as policies attached to a table, each with a condition that must evaluate true for a row to be visible (or writable). In PostgreSQL, the pattern looks roughly like this:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Once enabled, every SELECT, UPDATE, and DELETE against orders is silently rewritten to include that condition. A query with no WHERE clause at all — SELECT * FROM orders — returns only the rows the policy permits. Separate USING clauses control what’s visible for reads, and WITH CHECK clauses control what new or modified rows are allowed to satisfy, so you can allow a user to see rows they can’t create, or vice versa.
Policies can be layered: a table can have multiple policies for different roles or operations, combined with AND/OR depending on how they’re declared. Superusers and roles with BYPASSRLS skip the checks entirely, which matters for migrations, admin tooling, and backup jobs that legitimately need unrestricted access.
Where the tenant context comes from
The tricky part of RLS isn’t the policy syntax — it’s reliably telling the database who’s asking. A common pattern sets a session variable at the start of each connection or transaction, populated from the authenticated user’s JWT or session:
SET app.tenant_id = 'a1b2c3d4-...';
The application layer’s job shrinks from “remember to filter every query” to “set the session context correctly once per connection” — a much smaller surface to get wrong, and one that fails safe: if the variable is never set, most policies simply return zero rows rather than everything.
RLS vs application-layer filtering
| Application-layer filtering | Row-level security | |
|---|---|---|
| Enforcement point | Every query, in code | Database engine |
| Fails safe? | No — a missed filter leaks data | Usually — unset context returns nothing |
| Covers raw SQL / admin tools | No | Yes |
| Performance | No extra overhead | Adds a predicate to every query |
| Complexity | Spread across the codebase | Centralized in schema |
| Best for | Simple, single-tenant apps | Multi-tenant SaaS, regulated data |
RLS isn’t a replacement for RBAC or ABAC at the application layer — those still decide what a user is allowed to do in the product. RLS is a backstop that decides what rows a connection is allowed to touch, independent of the application logic on top. The two work well together: RBAC/ABAC governs feature access and business rules, RLS guarantees tenant isolation even when something upstream gets it wrong.
When it’s worth adopting
RLS earns its keep in multi-tenant systems where a data leak between tenants is a serious incident — SaaS platforms, healthcare and financial applications, anything with regulatory isolation requirements. It’s also useful when multiple applications or internal tools query the same PostgreSQL database directly, since a shared policy protects all of them without duplicating filter logic in each.
It’s less necessary for single-tenant applications, internal tools with a small trusted user base, or systems where SQL vs NoSQL choices already push you toward document stores that isolate tenants by database or collection rather than by row.
Costs and caveats
RLS isn’t free. Every enabled table adds a predicate to every query plan, which can defeat index usage if the policy condition isn’t itself indexed — pair tenant-scoping columns with an index, the same way you would for any Postgres index type you rely on for filtering. Policies also add a layer of logic that’s easy to forget exists: a developer debugging “missing” rows may not think to check for an active RLS policy, and complex policies with subqueries can be hard to reason about and slow to plan.
RLS also doesn’t protect against every threat. A connection with BYPASSRLS or superuser privileges skips it entirely, so it’s not a substitute for zero trust network controls or a WAF in front of the application. And because policies run inside transactions, they interact with MVCC snapshots — a row visible at the start of a transaction stays visible for its duration even if the policy condition would now exclude it, which matters for long-running transactions.
The takeaway
Row-level security pushes tenant and access isolation down into the database engine, so a policy — not a hopefully-correct WHERE clause in every code path — decides what a query can see. It fails safer than application-layer filtering, protects against raw SQL and admin-tool leaks, and is worth the modest performance and complexity cost anywhere a cross-tenant data leak would be a real incident. It complements, rather than replaces, access control decisions made higher up the stack.
Tagged
Keep reading
Chisato · · 6 min read Microsoft August 2026 Patch Tuesday: DNS RCE, Zero-Day
Microsoft's August 2026 Patch Tuesday fixes 400+ CVEs, an exploited WinSock zero-day, and a wormable 9.8 Windows DNS Server RCE. What to patch first.
Chisato · · 4 min read OCSP vs CRL: How Certificate Revocation Works
OCSP and CRL are the two mechanisms browsers use to check if a TLS certificate has been revoked before its expiry date. Here's how each works.
Chisato · · 4 min read What Is a Watering Hole Attack?
A watering hole attack compromises a site its targets already trust, then waits for victims to visit — rather than phishing them directly.