Row-Level Security Policies
Implement row-level security policies in PostgreSQL. Learn how to restrict data access per user with hands-on steps, troubleshooting, and next steps in the tutorial.
Focus: implement row-level security policies
You've built a solid PostgreSQL schema, mastered joins, and learned to write efficient queries. But what happens when you need to enforce that a user can only see their own data — and nothing else? Hardcoding WHERE user_id = current_user in every query is a recipe for disaster; it's error-prone, easy to forget, and impossible to enforce at the database level. That's exactly the pain that row-level security (RLS) solves. In this lesson, you'll learn how to implement row-level security policies in PostgreSQL, turning your database into the single source of truth for data access control.
The problem this lesson solves
Imagine you're building a multi-tenant SaaS app where customers store invoices, messages, or personal notes. You've got a users table and an orders table, and every query needs to filter by the logged-in user. Without a centralized mechanism, you rely on every developer remembering to add WHERE user_id = $1. Forget it once, and one user can see another's data — a critical security breach.
You could also create a separate table per user, but that explodes your schema and makes cross-user analytics nearly impossible. The real solution is to enforce data isolation at the database level, so that no matter how a query is written — whether it's from an ORM, a raw SQL tool, or a misbehaving script — the database itself returns only the rows the user is allowed to see.
That's the problem this lesson solves: how to implement row-level security policies to guarantee that each database role only sees (or modifies) its own rows, without sprinkling WHERE clauses everywhere.
Core concept / mental model
Think of row-level security as a bouncer at the door of each table. Normally, anyone with access to a table can see every row — like a public library where every book is on the open shelf. RLS flips that: you install a bouncer who checks each row against a set of rules (called policies) before letting you see it.
Here's the mental model:
- Table: the room full of books (rows).
- Role: the person trying to enter (the database user).
- Policy: the bouncer's instruction list, which says "this role may see rows where
user_id = current_user". - Enable RLS: the act of locking the door so the bouncer's checks are mandatory.
Technically, RLS is implemented via CREATE POLICY statements that define SELECT, INSERT, UPDATE, or DELETE rules. When RLS is enabled on a table (using ALTER TABLE ... ENABLE ROW LEVEL SECURITY), every query against that table is automatically rewritten to include the policy's predicate — you don't have to change your SQL at all.
Key terms you'll see:
- FORCE ROW LEVEL SECURITY: Makes RLS apply even to the table owner (by default, the table owner bypasses RLS).
- PERMISSIVE vs RESTRICTIVE: Permissive policies are combined with OR; restrictive ones with AND. You'll mostly use permissive.
- USING: Defines the predicate for rows that are visible (for
SELECT) or that can be modified (forUPDATE/DELETE). - WITH CHECK: Defines the predicate for new rows inserted or updated (for
INSERT/UPDATE).
Pro tip: Think of
USINGas "the rows I can see", andWITH CHECKas "the rows I can create". They can be different — for example, you can see all active orders but only insert orders with a status of 'pending'.
How it works step by step
Here's the logical sequence to implement row-level security policies:
- Identify the table(s) that need RLS. These are usually tables containing sensitive data tied to a user or tenant.
- Enable RLS on the table using
ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Until you do, the table is open (unless you alsoFORCEit). - Create policies with
CREATE POLICYthat define the rules for each operation (SELECT,INSERT,UPDATE,DELETE). You can use a single policy that applies to all commands, or separate ones for fine-grained control. - Test with a non-superuser role. RLS doesn't apply to superusers or table owners by default, so you must test with a role that doesn't bypass RLS.
- Force RLS if needed to also restrict the table owner, which is often a good idea for production.
A typical policy template looks like this:
CREATE POLICY policy_name
ON table_name
FOR SELECT
USING (user_id = current_setting('app.current_user')::int);
Note: you can reference current_user (the role name) or a session variable like app.current_user that your application sets after authentication. The latter is more flexible because it lets you map an authenticated web user to a numeric user_id.
Hands-on walkthrough
Let's implement row-level security policies on a realistic orders table. We'll create a table, enable RLS, and create policies to restrict access per user.
1. Set up the table
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
product TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
);
INSERT INTO orders (user_id, product, amount, status) VALUES
(1, 'Laptop', 1200.00, 'shipped'),
(1, 'Mouse', 25.50, 'pending'),
(2, 'Keyboard', 75.00, 'delivered');
2. Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
Now, without any policies, the table is effectively empty for everyone except superusers and the owner. Let's verify:
SELECT * FROM orders;
You'll get zero rows. The bouncer is there, but he has no instructions — so he lets nobody in.
3. Create policies
We'll create permissives for SELECT, INSERT, UPDATE, and DELETE.
-- Allow users to see their own orders
CREATE POLICY select_own_orders
ON orders
FOR SELECT
USING (user_id = current_setting('app.current_user')::int);
-- Allow users to insert orders for themselves
CREATE POLICY insert_own_orders
ON orders
FOR INSERT
WITH CHECK (user_id = current_setting('app.current_user')::int);
-- Allow users to update only their pending orders
CREATE POLICY update_own_pending_orders
ON orders
FOR UPDATE
USING (user_id = current_setting('app.current_user')::int AND status = 'pending')
WITH CHECK (user_id = current_setting('app.current_user')::int);
-- Allow users to delete their own pending orders
CREATE POLICY delete_own_pending_orders
ON orders
FOR DELETE
USING (user_id = current_setting('app.current_user')::int AND status = 'pending');
4. Test with a restricted role
-- Create an application role and a test user role
CREATE ROLE app_user LOGIN PASSWORD 'secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
GRANT USAGE, SELECT ON SEQUENCE orders_id_seq TO app_user;
SET ROLE app_user;
SELECT set_config('app.current_user', '1', false);
-- Now query
SELECT * FROM orders;
Expected output:
id | user_id | product | amount | status
----+---------+---------+--------+---------
1 | 1 | Laptop | 1200.00 | shipped
2 | 1 | Mouse | 25.50 | pending
(2 rows)
Only user 1's orders appear. User 2's row is hidden. Try inserting an order for user 2:
INSERT INTO orders (user_id, product, amount) VALUES (2, 'Desk', 300.00);
This fails with a new row violates row-level security policy error — the WITH CHECK blocks it. Perfect.
5. Force RLS (optional)
If you want to make sure the table owner can't bypass RLS (bad practice, but sometimes needed), use:
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Now even the owner must obey the policies.
Troubleshooting tip: If you get zero rows after enabling RLS, double-check that you've created policies and that you're not the table owner (superusers bypass RLS). Also ensure
app.current_useris set correctly in your session.
Compare options / when to choose what
There are several ways to enforce data isolation in PostgreSQL. Here's a comparison:
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Row-Level Security (RLS) | Centralized, automatic, works with any query | Requires careful policy design; session variable management | Multi-tenant apps where a single database schema is used |
| Separate tables per tenant | Impossible to mix data | Schema explosion, cross-tenant analysis hard | Very small number of tenants (e.g., < 10) |
| Application-level filtering (WHERE in app) | Simple to implement | Easy to forget; DB not enforcing | Prototyping or low-security apps |
Views with current_user filter |
Hides columns, not rows; can combine | Views can be bypassed if direct SELECT is granted | Read-only reporting scenarios |
For most production multi-tenant applications, RLS is the winner. It's mandatory, unforgeable, and works transparently with ORMs like Django or Rails, as long as you set the session variable after authentication.
Variations to consider:
- RLS with a
tenant_idcolumn instead ofuser_id, if you need to scope by company rather than individual user. - Using a JWT claim or a custom GUC (like
app.current_user) that your application sets once per request. - Using
BY DEFAULTvsMANAGEDpolicy (Postgres 15+) to separate policy creation from table creation.
Troubleshooting & edge cases
- Superuser bypasses RLS: By default, superusers and the table owner are not subject to policies. Always test with a restricted role, and use
FORCE ROW LEVEL SECURITYif needed. - Zero rows after enabling RLS: This means no policy grants access. Create at least one permissive
SELECTpolicy. WITH CHECKfails onINSERT: YourINSERTmust satisfy theWITH CHECKpredicate. Ensure you're setting the correctuser_idin the statement.- Session variable not set: If you're using
current_setting('app.current_user'), you must callset_configbefore the query. Otherwise it returns NULL, and the policy filters out everything. - Policy conflicts: Multiple permissive policies are OR'ed together. If you need strict AND logic, use restrictive policies or combine predicates in one policy.
- Performance: RLS adds a predicate check to every query — index the columns used in your policy (e.g.,
user_id) to avoid full table scans. - Migration complexity: Enabling RLS on an existing table with data is fine, but test thoroughly — existing application code might break if it expects to see all rows.
Common mistakes to avoid:
- Forgetting to
ENABLE ROW LEVEL SECURITYafter creating policies. - Not testing with a non-owner role, leading to a false sense of security.
- Using
user_idinUSINGbut forgetting to set the session variable from the application. - Mixing permissive and restrictive policies without understanding how they combine.
What you learned & what's next
You now know how to implement row-level security policies in PostgreSQL. You can:
- Explain the problem RLS solves: enforcing data isolation at the database level.
- Enable RLS on a table and create policies for
SELECT,INSERT,UPDATE, andDELETE. - Test with a restricted role to verify the policies work.
- Choose between RLS, separate tables, and application-level filtering.
- Troubleshoot common issues like zero rows and
WITH CHECKfailures.
Your next step in the PostgreSQL Tutorial track is to explore secure functions and audit triggers. You'll learn how to combine RLS with functions that run with definer privileges to create powerful, secure APIs, and how to log all sensitive operations automatically. Armed with RLS knowledge, you're well on your way to building robust, production-grade database security.
Practice recap
Now it's your turn: create a small notes table, enable RLS, and add policies so users can only see and edit their own notes. Test by setting app.current_user to a specific ID and trying to access another user's row. This exercise will solidify your understanding of how USING and WITH CHECK work together.
Common mistakes
- Forgetting to
ENABLE ROW LEVEL SECURITYafter creating policies — without it, policies are ignored. - Testing as a superuser or table owner, who bypass RLS by default, leading to false confidence.
- Not setting the session variable (e.g.,
app.current_user) before queries, which makes policies filter out everything. - Using permissive policies when you need strict AND logic — permissive policies are OR'ed together, potentially exposing more rows than intended.
Variations
- Use a
tenant_idcolumn in policies instead ofuser_idto enforce multi-tenant isolation. - Pass user identity via a custom GUC or a session-level JWT claim rather than relying on
current_user(which maps to the database role). - Use Postgres 15+
MANAGEDpolicies to simplify policy lifecycle if you use many policy variants.
Real-world use cases
- A SaaS platform stores invoices per customer and needs to guarantee that each login sees only its own invoices.
- A healthcare app keeps patient records in one table but restricts access to only the assigned doctor's role—using RLS policies that reference the doctor's ID.
- A financial dashboard reads transactions from a shared table, but users must not see data from other companies—RLS scopes
SELECTtocompany_idfrom the session.
Key takeaways
- RLS lets you enforce row-level access at the database layer, independent of application code.
- Always enable RLS on the table and create policies for all relevant commands (SELECT, INSERT, UPDATE, DELETE).
- Policies use
USINGfor visibility andWITH CHECKfor insertion/update validation. - Test RLS with a non-owner, non-superuser role to confirm it works as expected.
- Index columns used in policy predicates to avoid performance degradation.
- Session variables like
app.current_userare a clean way to pass the authenticated user ID to RLS policies.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.