Skip to content
Home » All Posts » How to Implement PostgreSQL Row Level Security for Multi-Tenant SaaS

How to Implement PostgreSQL Row Level Security for Multi-Tenant SaaS

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.

Designing a Simple Multi-Tenant Schema in PostgreSQL - image 1

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.

Shared vs Tenant-Scoped Data

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_id column 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.

Step-by-Step: Enabling Row Level Security on Tenant Data - image 1

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 a tenant_id in your own lookup table.
  • Tenant in JWT claims: the token includes a tenant_id or tenant_slug claim 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:

  1. Authenticate the user and determine tenant_id.
  2. Grab a database connection from the pool.
  3. Run SET app.current_tenant = ... on that connection.
  4. Execute all queries for that request (RLS uses current_setting under the hood).
  5. 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 = %s right after acquiring a connection from the pool.
  • Java (HikariCP): use a connection wrapper or interceptor to issue the SET on checkout and RESET on check-in.
  • Go (database/sql): execute the SET once per request on the *sql.Conn you get from db.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 check current_user or session_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 behind SECURITY DEFINER functions 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 into application_name or similar fields and parse it inside policies, but I’ve seen that turn brittle quickly. Custom GUCs like app.current_tenant are 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.

Advanced RLS Patterns for Real-World SaaS - image 1

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 of GRANTs and compare them against a documented access model, using scripts that query information_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.

Common Pitfalls and How to Troubleshoot RLS in Multi-Tenant SaaS - image 1

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 forgot ALTER 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 as app_user but the policy is TO public or some unused role.
  • Tenant context not set (or stale) on pooled connections
    If app.current_tenant is 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 have SELECT, 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)

  1. Verify RLS state on the table:
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = 'projects';
  1. Inspect policies:
SELECT policyname, roles, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'projects';
  1. 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 have SELECT/INSERT/UPDATE/DELETE?
  • If GRANTs are correct, the error may come from WITH CHECK rejecting an insert/update. Try to reproduce the failing statement manually in psql to 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 with EXPLAIN or EXPLAIN ANALYZE as the affected role and inspect the plan for the RLS condition (your tenant_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 increase log_statement or use log_min_duration_statement for 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.

Join the conversation

Your email address will not be published. Required fields are marked *