Introduction: Why Row Level Security Matters for Multi-Tenant SaaS
When I build a multi-tenant SaaS, the first non‑negotiable requirement is strict tenant isolation. Every query, every API endpoint, and every admin tool has to guarantee that one customer can never see another customer’s data, even if I make a mistake elsewhere in the stack. That’s where PostgreSQL Row Level Security becomes a powerful ally.
In a typical PostgreSQL row level security multi-tenant SaaS design, all tenants share the same database and often the same tables. It’s efficient and cost‑effective, but it also means a single buggy WHERE clause like WHERE id = $1 instead of WHERE tenant_id = $tenant AND id = $1 can silently leak data. I’ve seen teams try to solve this only in the application layer with ORMs and middleware, and sooner or later someone forgets a filter.
PostgreSQL’s Row Level Security (RLS) lets me push that critical isolation rule down into the database itself. Instead of trusting every developer to remember tenant filters, I can define a policy like “users may only access rows where tenant_id = current_setting(‘app.current_tenant’)” and let Postgres enforce it on every SELECT, UPDATE, DELETE, and even INSERT.
By the end of this tutorial, you’ll have a working setup that:
- Stores multiple tenants in shared tables with a clear tenant_id pattern.
- Enables PostgreSQL Row Level Security for those tables.
- Adds robust RLS policies that enforce tenant isolation automatically.
- Uses a per-request tenant context (via SET and current_setting) so your application code stays simple and safe.
To make the idea concrete, I’ll use a minimal example schema and show how a policy changes what rows are visible for different tenants:
CREATE TABLE invoices (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
customer text NOT NULL,
amount_cents integer NOT NULL
);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Everything else in the article will build on this foundation so you can confidently run a secure, shared-database multi-tenant SaaS in production.
Prerequisites and Assumptions
To keep the focus on designing PostgreSQL row level security for a multi-tenant SaaS, I’m going to assume a few things about your background and environment. When I first implemented RLS in production, having these basics in place made the process far smoother.
Skills and Knowledge
You’ll get the most value from this guide if you’re comfortable with:
- Core SQL: creating tables, writing SELECT/INSERT/UPDATE/DELETE queries, and basic joins.
- PostgreSQL administration basics: connecting with psql or a GUI, creating databases, and managing roles.
- Application development: using any server-side language (Node.js, Python, Java, etc.) to connect to Postgres and run parameterized queries.
- General SaaS and multi-tenant concepts: tenants, user accounts, and shared vs. isolated schemas.
If any of these feel rusty, a quick refresher on PostgreSQL roles and privileges will help you understand why the RLS policies we create behave the way they do. PostgreSQL Documentation: Chapter 21. Database Roles
Tools and Environment
Throughout the tutorial, I’ll assume the following setup, which mirrors what I typically use in real projects:
- PostgreSQL 12+ (RLS exists earlier, but I’ve found 12+ is a good baseline for modern features).
- Command-line access via psql or an equivalent SQL client.
- One dedicated database user for the application (not a superuser), which we’ll lock down with RLS.
- A local or dev environment where you can safely create and drop tables, roles, and policies.
We’ll use simple SQL examples you can paste directly into psql or your favorite client, then wire them into your app once you’re happy with the behavior.
Designing a Simple Multi-Tenant Schema in PostgreSQL
Before turning on PostgreSQL Row Level Security, I like to start with a clean, predictable schema. In a PostgreSQL row level security multi-tenant SaaS, the goal is simple: every row that belongs to a tenant must be clearly marked, so RLS policies have something reliable to filter on.
Choosing a Tenant Identifier Strategy
In my own projects I almost always use a UUID tenant_id as the primary way to link data to a tenant. It’s stable, opaque, and easy to pass through APIs. At minimum, you’ll need a table to represent tenants and a consistent data type for their IDs:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Using a single tenants table like this works well whether you have ten customers or ten thousand. I’ve found that getting this ID strategy right early saves a lot of migration pain later.
Creating Tenant-Scoped Resource Tables
Next, we define the tables that will actually be protected by RLS. For this tutorial, I’ll use a simple projects and invoices model where every row belongs to exactly one tenant:
CREATE TABLE projects (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE invoices (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
project_id bigint NOT NULL REFERENCES projects(id),
amount_cents integer NOT NULL,
status text NOT NULL DEFAULT 'draft',
issued_at timestamptz NOT NULL DEFAULT now()
);
The key pattern here is that tenant_id is a first-class column on every tenant-scoped table, not just something you can infer via a long chain of joins. In my experience, this explicitness makes RLS policies simpler and queries easier to reason about.
Most real SaaS apps mix truly shared data (like system-level configuration) with tenant-specific data. Only the tenant-specific tables need RLS, but you still want their design to be consistent. I usually follow this rule of thumb:
- Tenant-scoped tables: must have a
tenant_idcolumn and will get RLS policies. - Global/shared tables: no
tenant_id; RLS is typically disabled or used only for special cases.
With this schema in place, we’re ready to turn on Row Level Security and define policies that guarantee each tenant only sees its own projects and invoices, regardless of what the application code does.
How PostgreSQL Row Level Security Works
Before wiring RLS into a production-grade PostgreSQL row level security multi-tenant SaaS, I like to step back and understand how Postgres actually enforces it. Once I internalized the mental model—tables have policies, policies are evaluated per role, and those policies behave like invisible WHERE clauses—debugging odd query results became much easier.
RLS as Invisible WHERE Clauses
At its core, Row Level Security tells PostgreSQL which rows a given role is allowed to see or modify. When RLS is enabled on a table, every SELECT, UPDATE, DELETE, and optionally INSERT is automatically filtered according to the active policies.
You can think of it like Postgres silently rewriting your queries. For example, if you run:
SELECT * FROM invoices;
and you have an RLS policy that says tenant_id = current_setting('app.current_tenant')::uuid, Postgres effectively behaves as if you had written:
SELECT * FROM invoices
WHERE tenant_id = current_setting('app.current_tenant')::uuid;
The difference is that with RLS, this filter can’t be forgotten by accident—it’s always enforced for non-bypass roles.
Roles, Privileges, and Policy Enforcement
RLS doesn’t replace normal privileges; it layers on top. In my own setups, I follow this mental model:
- GRANT controls what operations a role may attempt on a table (SELECT, INSERT, UPDATE, DELETE).
- RLS policies control which rows those operations can touch once they’re allowed.
- Roles with BYPASSRLS (typically superusers) ignore RLS entirely, which is handy for migrations and admin tools but dangerous for application users.
To see RLS in action end-to-end, I often create a dedicated app role and grant it basic privileges, then let policies do the heavy lifting:
CREATE ROLE app_user LOGIN PASSWORD 'example';
GRANT CONNECT ON DATABASE my_saas TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Here, the USING clause limits which rows can be read or modified; the WITH CHECK clause ensures new or updated rows still belong to the current tenant. One thing I learned the hard way was to always set both, or you can end up with rows that no tenant can ever see.
If you’re not fully comfortable with GRANTs and roles, it’s worth reviewing a concise Chapter 21. Database Roles – PostgreSQL Documentation before layering RLS on top, because RLS assumes your basic permissions model is already sensible.
Policy Types and Default Behavior
PostgreSQL gives you fine-grained control over which operations each policy applies to:
- FOR SELECT – controls which rows are visible in queries.
- FOR INSERT – controls which rows can be inserted (via
WITH CHECK). - FOR UPDATE – controls both which rows can be updated and what they can be updated to.
- FOR DELETE – controls which rows can be deleted.
If you declare FOR ALL, the policy applies to all four operations, which is often what I want in a straightforward multi-tenant design. Another important default: once you enable RLS on a table, no rows are visible or modifiable unless a policy explicitly allows it. This secure-by-default behavior is exactly why I trust RLS as a safety net around my app code.
With this conceptual model in place, the next step is to wire RLS to the tenant context so Postgres always knows which tenant is making the request.
Step-by-Step: Enabling Row Level Security on Tenant Data
Now that the schema and concepts are clear, it’s time to actually turn on Row Level Security for our tenant data. When I roll this out in a real PostgreSQL row level security multi-tenant SaaS, I follow a predictable sequence: prepare a safe app role, enable RLS on the right tables, add strict policies, then test behavior from the app’s perspective.
1. Create a Dedicated Application Role
I never use a superuser or owner role from the application. Instead, I create a dedicated role that has just enough privileges, and then let RLS narrow things down further.
-- Create a login role for the app
CREATE ROLE app_user LOGIN PASSWORD 'example_password';
-- Allow connecting to the database and using the public schema
GRANT CONNECT ON DATABASE my_saas TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
-- Grant base privileges on tenant tables
GRANT SELECT, INSERT, UPDATE, DELETE ON
projects,
invoices
TO app_user;
At this point, if RLS weren’t enabled, app_user could see and modify everything in those tables. That’s exactly what we’re about to lock down.
2. Enable Row Level Security on Tenant Tables
Next, we turn on RLS at the table level. In my experience, it’s safer to enable RLS on one table at a time and test, rather than flipping it on everywhere at once.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY; ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
Once RLS is enabled, PostgreSQL becomes deny-by-default for non-bypass roles: no rows are visible or modifiable until a policy explicitly allows them. If you try a simple query as app_user now, you’ll get an empty result set even if the tables contain data.
3. Define a Tenant Context via current_setting
To let RLS know which tenant an app request belongs to, I use a per-connection setting. This keeps the SQL policies clean and keeps tenant awareness out of every individual query. Here’s the pattern I typically use:
-- Define a custom GUC (configuration parameter) namespace
-- (no DDL needed, we can just start using it).
-- From the application, set this at the start of each request:
SET app.current_tenant = '8b3a3b3a-0c07-4ef9-8db6-9c1f0b111234';
-- In SQL, read it like this:
SELECT current_setting('app.current_tenant');
In code, I usually run the SET immediately after checking the user’s session or JWT. For example, a minimal Python snippet using psycopg2 might look like this:
import psycopg2
conn = psycopg2.connect(dsn)
cur = conn.cursor()
# tenant_id comes from authenticated user/session
tenant_id = "8b3a3b3a-0c07-4ef9-8db6-9c1f0b111234"
# Set the tenant context for this connection
cur.execute("SET app.current_tenant = %s", (tenant_id,))
# All subsequent queries on this connection will be filtered by RLS
cur.execute("SELECT id, name FROM projects ORDER BY id")
rows = cur.fetchall()
One thing I learned the hard way was to be very strict about resetting this context in connection pools; stale tenant settings can cause very confusing cross-tenant leaks if you’re not careful.
4. Add RLS Policies for tenant_id-Based Isolation
With the tenant context in place, we can finally add the core policies that make isolation work. I like to start with a single FOR ALL policy per table that covers read and write operations, and only split it later if I need special cases.
-- Projects: every row must match the current tenant
CREATE POLICY projects_tenant_isolation ON projects
FOR ALL
TO app_user
USING (
tenant_id = current_setting('app.current_tenant')::uuid
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant')::uuid
);
-- Invoices: also ensure the related project belongs to the tenant
CREATE POLICY invoices_tenant_isolation ON invoices
FOR ALL
TO app_user
USING (
tenant_id = current_setting('app.current_tenant')::uuid
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant')::uuid
);
Here’s how I think about these clauses:
- USING filters which existing rows are visible and can be targeted by UPDATE/DELETE.
- WITH CHECK validates new or updated rows, preventing the app from inserting or changing data to a different tenant.
With this in place, app_user is effectively sandboxed to a single tenant per connection, no matter what queries the application sends.
5. Test RLS Behavior as the Application User
Finally, I like to run a quick manual test from psql pretending to be the app. This has saved me more than once by catching a missing policy before it hit production.
-- Connect as app_user
\c my_saas app_user
-- Set the tenant context for this session
SET app.current_tenant = '8b3a3b3a-0c07-4ef9-8db6-9c1f0b111234';
-- Only this tenant's rows should be visible
SELECT id, tenant_id, name FROM projects;
-- Attempt to insert a row for a different tenant should fail
INSERT INTO projects (tenant_id, name)
VALUES ('00000000-0000-0000-0000-000000000000', 'Should be rejected');
If the policies are correct, the SELECT will only return rows matching the current tenant, and the INSERT with a mismatched tenant_id will be blocked by the WITH CHECK condition. Once I see this behavior working end-to-end, I’m confident that the database is enforcing tenant isolation consistently, even if someone makes a mistake in the application code later.
Passing Tenant Context from Your SaaS Application
Row Level Security only works as well as the tenant context you feed into PostgreSQL. In every PostgreSQL row level security multi-tenant SaaS I’ve worked on, the most fragile part wasn’t the policies themselves, but how reliably the app told Postgres, “this request belongs to tenant X.” If that mapping is wrong or missing, RLS can’t protect you.
Where Tenant Identity Comes From
In a typical SaaS backend, the tenant identity is established during authentication and then propagated through the request lifecycle. Common patterns I’ve used include:
- Subdomain-based tenancy (e.g.,
acme.example.com): the tenant is derived from the hostname and mapped to atenant_idin your own lookup table. - Tenant in JWT claims: the token includes a
tenant_idortenant_slugclaim that you trust after verification. - Tenant in session or API key metadata: the tenant is looked up once when the user logs in or when the API key is created, then cached in your session store.
Once you have a trusted tenant_id in memory, the backend’s job is to pass it to Postgres in a way RLS policies can read—without the application manually adding WHERE tenant_id = ... to every query.
Using SET and current_setting Safely (with Connection Pools)
The simplest and most robust method I’ve used is a per-connection configuration parameter, usually in the app.* namespace, set at the start of each request. The key is to integrate this with your connection pool so settings don’t leak between tenants.
Conceptually, each request does something like this:
- Authenticate the user and determine
tenant_id. - Grab a database connection from the pool.
- Run
SET app.current_tenant = ...on that connection. - Execute all queries for that request (RLS uses
current_settingunder the hood). - Optionally reset or clear the setting before returning the connection to the pool.
Here’s a concrete Node.js example using node-postgres (pg) that mirrors what I run in production:
// Node.js example with node-postgres
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function withTenantConnection(tenantId, callback) {
const client = await pool.connect();
try {
// Ensure we never reuse a connection with the wrong tenant
await client.query('SET app.current_tenant = $1', [tenantId]);
// Optionally, you can also set a request id or user id here
return await callback(client);
} finally {
// Clear tenant context to avoid surprising behavior
await client.query("RESET app.current_tenant");
client.release();
}
}
// Usage in a request handler
async function listProjectsHandler(req, res) {
const tenantId = req.auth.tenantId; // from JWT or session
const rows = await withTenantConnection(tenantId, async (client) => {
const { rows } = await client.query(
'SELECT id, name FROM projects ORDER BY id'
);
return rows;
});
res.json(rows);
}
One thing I learned the hard way was that forgetting to reset the setting when releasing a connection can cause cross-tenant data to show up in the wrong request, especially under high concurrency. I now treat RESET app.current_tenant as mandatory whenever I roll my own pooling helpers.
This same pattern applies in other languages:
- Python (psycopg / SQLAlchemy): run
SET app.current_tenant = %sright after acquiring a connection from the pool. - Java (HikariCP): use a connection wrapper or interceptor to issue the
SETon checkout andRESETon check-in. - Go (database/sql): execute the
SETonce per request on the*sql.Connyou get fromdb.Conn(ctx).
As long as RLS policies only rely on current_setting('app.current_tenant') and you keep this pattern consistent, the database always knows which tenant it’s dealing with, regardless of the query text.
Alternatives: SET ROLE, Security Definer, and Session Auth
While SET app.current_tenant has been the cleanest option for me, there are other approaches that might fit your stack or constraints better. Each comes with its own trade-offs.
- SET ROLE per tenant
In some designs, you create a separate DB role for each tenant and encode the tenant identity in the role name (e.g.,tenant_123). RLS policies then checkcurrent_userorsession_user. I’ve found this doesn’t scale well beyond a modest number of tenants and makes role management messy, so I usually avoid it for large SaaS deployments. - Security definer functions
Another option is to hide all tenant access behindSECURITY DEFINERfunctions that enforce tenant_id filters internally. RLS can still complement this, but you lose some of the transparency of “any query is safe.” I’ve only used this pattern for very sensitive operations where I wanted to tightly control the allowed query shapes. - Using application_name or session variables
Some teams stuff tenant info intoapplication_nameor similar fields and parse it inside policies, but I’ve seen that turn brittle quickly. Custom GUCs likeapp.current_tenantare explicitly intended for this use case and are far easier to reason about.
Whichever approach you choose, the guiding principle is the same: map each incoming request to a single, trusted tenant identity inside Postgres, and then let Row Level Security enforce the rest. If you want to dive deeper into patterns for mixing RLS with pooled connections and middle-tier frameworks, a focused resource on Mastering Database Connection Pooling is well worth a read.
Advanced RLS Patterns for Real-World SaaS
Once the basics are in place, real projects quickly demand more from a PostgreSQL row level security multi-tenant SaaS: different privileges inside a tenant, safe cross-tenant admin views, and sometimes even per-tenant customizations. In my own deployments, these are the patterns that separated a simple demo from something operations could trust over years.
Per-Tenant Roles and Delegated Access
One useful pattern is to distinguish between different types of users inside the same tenant—owners, regular members, maybe read-only roles—without duplicating RLS logic. I’ve had good results by keeping a single app_user-style DB role, but enriching the tenant context with a role or permissions flag:
-- Example: encode tenant role in a second setting
SET app.current_tenant = '8b3a3b3a-0c07-4ef9-8db6-9c1f0b111234';
SET app.tenant_role = 'owner'; -- or 'member', 'viewer', etc.
CREATE POLICY invoices_owner_extra ON invoices
FOR UPDATE, DELETE
TO app_user
USING (
tenant_id = current_setting('app.current_tenant')::uuid
AND current_setting('app.tenant_role', true) = 'owner'
);
In the app layer, I set app.tenant_role based on the user’s role within their tenant. This lets me express fine-grained rules in SQL while keeping a single database role and simple GRANTs.
Cross-Tenant Admin and Support Access
At some point, your support or operations team will need to inspect tenant data. I used to bypass RLS for this with a superuser, but that makes accidental mistakes far too easy. Now I prefer a dedicated support_user role with carefully scoped policies:
CREATE ROLE support_user LOGIN PASSWORD 'support_password'; GRANT CONNECT ON DATABASE my_saas TO support_user; GRANT USAGE ON SCHEMA public TO support_user; GRANT SELECT ON projects, invoices TO support_user; ALTER TABLE projects ENABLE ROW LEVEL SECURITY; ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; -- Allow support to see any tenant, but read-only CREATE POLICY support_read_all_projects ON projects FOR SELECT TO support_user USING (true); CREATE POLICY support_read_all_invoices ON invoices FOR SELECT TO support_user USING (true);
Because support_user has only SELECT privileges, these policies give full read visibility across tenants without risking writes. When I need even tighter controls, I add conditions (e.g., only active tenants, or only anonymized views) rather than ever giving BYPASSRLS.
Soft Deletes, Audit, and Time-Based Policies
Real SaaS apps often need soft deletes and auditing. RLS can help enforce that deleted or archived data isn’t visible to normal users, while still being queryable for compliance or back-office tools. A simple pattern I’ve used is an is_deleted flag combined with role-aware policies:
ALTER TABLE invoices ADD COLUMN is_deleted boolean NOT NULL DEFAULT false;
-- Normal app users never see deleted rows
CREATE POLICY app_invoices_active_only ON invoices
FOR SELECT
TO app_user
USING (
tenant_id = current_setting('app.current_tenant')::uuid
AND is_deleted = false
);
-- Support can see everything, including deleted
CREATE POLICY support_invoices_all ON invoices
FOR SELECT
TO support_user
USING (true);
In my experience, pushing these rules down into RLS is worth the effort: I no longer worry that a forgotten WHERE is_deleted = false in one query will leak data to end users, because the database itself enforces the rule for every access path.
Verifying and Testing Your PostgreSQL Row Level Security Setup
Once RLS is wired into a PostgreSQL row level security multi-tenant SaaS, I treat verification as a separate, deliberate phase. In my experience, the biggest failures weren’t missing policies, but silent mistakes: a forgotten role, a pool reusing connections with the wrong tenant context, or an admin path bypassing RLS entirely. A bit of systematic testing up front saves a lot of late-night incident calls.
Manual Checks with psql (Per Role, Per Tenant)
I always start by simulating real users from psql. The goal is to prove that:
- Normal app roles can only see their own tenant data.
- Support/admin roles behave exactly as designed (e.g., read-only, cross-tenant, etc.).
- Superusers can still bypass RLS when necessary.
Here’s a minimal flow I use when testing an app_user role:
-- Connect as the application role
\c my_saas app_user
-- Tenant A
SET app.current_tenant = '11111111-1111-1111-1111-111111111111';
SELECT tenant_id, id, name FROM projects ORDER BY id;
-- Switch to Tenant B
SET app.current_tenant = '22222222-2222-2222-2222-222222222222';
SELECT tenant_id, id, name FROM projects ORDER BY id;
-- Try to "cheat" by forcing a different tenant_id
INSERT INTO projects (tenant_id, name)
VALUES ('11111111-1111-1111-1111-111111111111', 'Should be blocked');
For a correct setup, each SET app.current_tenant should only return that tenant’s rows, and the INSERT with a mismatched tenant_id should fail due to the WITH CHECK policy. I also run the same queries as support_user (if you have one) to confirm their broader read access behaves as intended.
Automated Tests in Your Application Code
After the manual sanity check, I like to bake RLS expectations into the test suite. That way, if someone changes a policy or grants a new privilege, CI catches regressions before production. An example in Python with pytest and psycopg2 might look like this:
import psycopg2
DSN = "dbname=my_saas user=app_user password=example_password"
TENANT_A = "11111111-1111-1111-1111-111111111111"
TENANT_B = "22222222-2222-2222-2222-222222222222"
def run_for_tenant(tenant_id, sql, params=None):
conn = psycopg2.connect(DSN)
try:
cur = conn.cursor()
cur.execute("SET app.current_tenant = %s", (tenant_id,))
cur.execute(sql, params or [])
return cur.fetchall()
finally:
conn.close()
def test_tenant_cannot_see_other_tenant_projects():
rows_a = run_for_tenant(TENANT_A, "SELECT tenant_id FROM projects")
rows_b = run_for_tenant(TENANT_B, "SELECT tenant_id FROM projects")
assert all(r[0] == TENANT_A for r in rows_a)
assert all(r[0] == TENANT_B for r in rows_b)
I also add negative tests (e.g., asserting that an insert with the wrong tenant_id raises an error) so I know both USING and WITH CHECK clauses are doing their jobs. In my own teams, we treat these RLS tests as “guardrail tests” that should never be removed.
Inspecting Policies and Logging Suspicious Access
Finally, I like to verify what’s actually configured at the database level and keep an eye on it over time.
- Inspect active policies
Use the catalog views to list policies and confirm they match what you expect:
SELECT tablename, policyname, roles, cmd, qual, with_check FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename, policyname;
- Enable targeted logging
In environments where I’m nervous about leaks (like early rollout), I enable logging of statements for specific roles or tables, then comb through them for anything that looks off. Combining that with connection-level metadata (request IDs, user IDs) gives a clear audit trail of who touched what, when. - Review GRANTs regularly
RLS assumes your basic privileges are sane. I’ve found it useful to schedule a periodic review ofGRANTs and compare them against a documented access model, using scripts that queryinformation_schema.role_table_grants.
If you’re building a more rigorous verification approach (for example, in a regulated environment), it’s worth looking at a focused Comprehensive PostgreSQL Security Checklist & Tips | EDB so RLS testing becomes part of a broader database security review rather than a one-off exercise.
Common Pitfalls and How to Troubleshoot RLS in Multi-Tenant SaaS
Even with a solid design, I’ve seen the same PostgreSQL row level security multi-tenant SaaS issues crop up again and again: empty result sets, mysterious permission errors, or worse, unexpected cross-tenant data. The good news is that most of these come from a small set of misconfigurations that are straightforward to diagnose once you know where to look.
Typical Misconfigurations to Watch For
Here are the RLS mistakes I run into most often:
- RLS not actually enabled on the table
You created a policy but forgotALTER TABLE ... ENABLE ROW LEVEL SECURITY, so nothing is enforced. - USING but no WITH CHECK (or vice versa)
Policies that only filter reads but don’t validate writes, leading to rows that no tenant can see or to incorrect tenant_ids being inserted. - Policies attached to the wrong role
E.g., the app connects asapp_userbut the policy isTO publicor some unused role. - Tenant context not set (or stale) on pooled connections
Ifapp.current_tenantis missing or left from a previous request, your policies either see nothing or see the wrong tenant. - GRANTs too permissive or missing
RLS doesn’t override basic privileges; if the role doesn’t haveSELECT, it can’t read even if policies allow it; if another role has overly broad GRANTs plus permissive policies, it may see more than intended.
One thing I learned early on was to always check GRANTs, policies, and session settings together; looking at just one of them rarely tells the whole story.
Debugging Empty Results vs. Permission Errors
When something breaks, I mentally sort it into two buckets: the app sees no rows, or the app sees an error. The debugging steps differ slightly.
Case 1: Queries return no rows (but should)
- Verify RLS state on the table:
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'projects';
- Inspect policies:
SELECT policyname, roles, cmd, qual, with_check FROM pg_policies WHERE tablename = 'projects';
- Check tenant context and role in-session:
SELECT current_user, session_user,
current_setting('app.current_tenant', true) AS tenant;
If app.current_tenant is NULL or doesn’t match any rows, RLS will legitimately filter everything out. I’ve fixed more than one “bug” by realizing the wrong tenant_id was being set from the app.
Case 2: Permission denied errors
- First check
GRANTs on the table: does the role actually haveSELECT/INSERT/UPDATE/DELETE? - If GRANTs are correct, the error may come from
WITH CHECKrejecting an insert/update. Try to reproduce the failing statement manually inpsqlto see the exact error text.
In more stubborn cases, I temporarily relax a policy (in a non-production environment) or add a dedicated testing policy like USING (true) for a specific role to isolate whether the issue is RLS logic vs. something else.
Tracing RLS Behavior with Logs and EXPLAIN
When issues are subtle, I rely on Postgres itself to show me what it’s doing. Two techniques have helped me a lot:
- Use EXPLAIN to confirm filters are applied
Run your query withEXPLAINorEXPLAIN ANALYZEas the affected role and inspect the plan for the RLS condition (yourtenant_id = current_setting(...)expression). If you don’t see it, the policy might not be attached to the right role or command type. - Enable targeted statement logging
Temporarily increaselog_statementor uselog_min_duration_statementfor specific roles to capture exactly what’s being run and under which user. Combined with logging connection parameters (like tenant_id, user_id, request ID), you can correlate suspicious queries back to application code.
Here’s a small helper query I often use while debugging in development:
-- Show what the current session "looks like" to RLS
SELECT
current_user,
session_user,
current_setting('app.current_tenant', true) AS tenant,
inet_client_addr() AS client_ip;
Seeing that output side-by-side with the policies from pg_policies usually makes the root cause obvious. Once you get comfortable with these checks, troubleshooting RLS becomes much less mysterious and you can roll out changes with far more confidence.
Conclusion and Next Steps for Secure PostgreSQL Multi-Tenancy
By this point, you’ve walked through the core building blocks of a PostgreSQL row level security multi-tenant SaaS: a shared schema with a clear tenant_id, RLS enabled on key tables, minimal but strict policies, and a reliable way for your backend to pass tenant context into Postgres on every request. In my own projects, that combination has been the single biggest safeguard against cross-tenant leaks, even when application code evolves quickly.
What You’ve Achieved
You now have a database that enforces tenant isolation by default. Even if a developer forgets a WHERE clause, RLS policies still protect you. You’ve also seen how to support richer real-world needs—like per-tenant roles, support access, and soft deletes—without abandoning that safety net. For a SaaS product that needs to scale without sacrificing trust, this foundation is hard to beat.
Where to Go from Here
From here, the next steps I usually tackle are: tightening GRANTs and auditing policies regularly, adding more automated tests around RLS behavior, and planning for scale with connection pooling, read replicas, and possibly sharding by tenant. Over time, you can layer in encryption, stronger auditing, and more advanced access patterns—but with RLS at the core, your multi-tenant database is already doing a lot of the heavy lifting for you.

Hi, I’m Cary Huang — a tech enthusiast based in Canada. I’ve spent years working with complex production systems and open-source software. Through TechBuddies.io, my team and I share practical engineering insights, curate relevant tech news, and recommend useful tools and products to help developers learn and work more effectively.





