Postgres RLS with JWTs for Granular Multi-Tenant SaaS Isolation

15 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Fallacy of Application-Layer Tenant Isolation

In the world of multi-tenant SaaS applications, data isolation is a non-negotiable security requirement. The conventional approach is deceptively simple: diligently add a WHERE tenant_id = :current_tenant_id clause to every single database query. While functional on the surface, this pattern is a ticking time bomb for any sufficiently complex system. It's brittle, error-prone, and decentralizes critical security logic across hundreds or thousands of lines of application code.

A single forgotten WHERE clause in a complex JOIN or a deeply nested repository method can lead to a catastrophic data leak, exposing one tenant's sensitive information to another. Code reviews can miss it. Static analysis might not catch it. This architectural fragility simply doesn't scale and keeps security architects up at night.

The solution is to push the responsibility for data isolation down the stack, from the fallible application layer to the robust, transactional database layer. PostgreSQL's Row-Level Security (RLS) provides the perfect mechanism to achieve this. By defining security policies directly on the tables, we can create a powerful, centralized, and transparent enforcement layer. This article details a production-ready pattern for driving these RLS policies using claims from a JSON Web Token (JWT), creating a seamless and highly secure multi-tenant architecture.


Core Architecture: JWT Claims as a Security Context

Our strategy hinges on passing the authenticated user's context, specifically their tenant_id and role, from the application to the database within a single transaction. The database will then use this context to enforce RLS policies.

Here's the request lifecycle:

  • Authentication: A user authenticates with the system (e.g., via a login endpoint) and receives a JWT.
  • JWT Content: This JWT is signed and contains critical claims for our authorization model. At a minimum, it must include the tenant_id. For more granular control, it can also include user_id, role (e.g., 'admin', 'editor', 'viewer'), or other permissions.
  • json
        {
          "sub": "user-123",
          "tenant_id": "acme-corp-456",
          "role": "editor",
          "exp": 1678886400
        }
  • API Request: The client sends this JWT in the Authorization header of subsequent API requests.
  • Middleware & Transaction: The application's API middleware performs two key actions:
  • a. Validates the JWT signature and expiration.

    b. For every incoming request that requires database access, it starts a database transaction.

    c. Within that transaction, it sets a session-local configuration parameter containing the JWT claims. This is the most critical step. We use SET LOCAL to ensure the setting is scoped only to the current transaction, preventing catastrophic data leaks in a connection-pooled environment.

  • Database Query: The application code now executes its queries as if it were in a single-tenant environment. The code is clean and unaware of the multi-tenancy logic: SELECT * FROM projects WHERE status = 'active';.
  • RLS Policy Enforcement: PostgreSQL intercepts this query. Before execution, it applies the RLS policy defined on the projects table. The policy reads the session-local configuration parameter, parses the JWT claims, and dynamically adds the necessary WHERE condition (e.g., WHERE tenant_id = 'acme-corp-456') to the query. If the context is missing or invalid, no rows are returned.
  • This architecture centralizes the security logic in one place—the database schema—making it auditable, robust, and completely decoupled from the application's business logic.

    Schema and Policy Implementation

    Let's build this system from the ground up. We'll define a simple schema for tenants, users, and projects.

    1. Database Schema Setup

    First, we create our tables. Note the ubiquitous tenant_id column, which will be the key for our RLS policies.

    sql
    -- Enable the pgcrypto extension for UUID generation
    CREATE EXTENSION IF NOT EXISTS "pgcrypto";
    
    -- Tenants table
    CREATE TABLE tenants (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name TEXT NOT NULL UNIQUE,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    
    -- Projects table, linked to a tenant
    CREATE TABLE projects (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
        name TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    
    -- Create indexes for performance
    CREATE INDEX ON projects (tenant_id);
    
    -- Enable Row-Level Security on the projects table
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    
    -- Forcing RLS for the table owner as well is a good security practice
    ALTER TABLE projects FORCE ROW LEVEL SECURITY;

    2. Crafting the RLS Policies

    Now, we define the policies. Our goal is to allow operations only if the tenant_id in the row matches the tenant_id from the JWT claims we'll set in the session.

    We'll create a helper function to reliably extract the tenant_id from the JWT claims JSON string. This encapsulates the logic and makes policies cleaner.

    sql
    -- Helper function to safely extract tenant_id from the JWT claims JSON.
    -- The `->>` operator extracts a JSON object field as text.
    CREATE OR REPLACE FUNCTION get_current_tenant_id() RETURNS UUID AS $$
    BEGIN
        -- current_setting can throw an error if the setting is not found.
        -- The 'true' second parameter makes it return NULL instead.
        RETURN (current_setting('app.jwt.claims', true)::jsonb ->> 'tenant_id')::uuid;
    EXCEPTION
        -- Handle cases where the claim is not a valid UUID or JSON is malformed
        WHEN OTHERS THEN
            RETURN NULL;
    END;
    $$ LANGUAGE plpgsql STABLE;

    With the helper function in place, the policy definition becomes remarkably clean.

    sql
    -- Policy for SELECT, UPDATE, DELETE operations
    -- This policy ensures that a user can only see or modify projects
    -- belonging to their own tenant.
    CREATE POLICY tenant_isolation_policy ON projects
        FOR ALL
        USING (tenant_id = get_current_tenant_id())
        WITH CHECK (tenant_id = get_current_tenant_id());

    Let's break down this CREATE POLICY statement:

    * FOR ALL: This applies the policy to all commands (SELECT, INSERT, UPDATE, DELETE).

    USING (tenant_id = get_current_tenant_id()): This clause applies to rows that are returned* by a query (SELECT) or are candidates for modification (UPDATE, DELETE). The query will only act on rows where this condition is true.

    WITH CHECK (tenant_id = get_current_tenant_id()): This clause applies to rows that are being created (INSERT) or modified* (UPDATE). It prevents a user from inserting a project with a tenant_id other than their own, or updating a project to a different tenant_id.

    At this point, if you try to query the projects table as any user, you will get zero rows back, because the app.jwt.claims setting is not yet defined.

    Application-Layer Integration: The Critical Bridge

    Now we need the application to provide the context to PostgreSQL. We'll use a Node.js with Express and the pg library for this example, but the pattern is identical in any language (Go, Python, Rust, Java).

    1. The Middleware

    This Express middleware will be the heart of our integration. It validates the JWT and sets the app.jwt.claims for the duration of the request's database transaction.

    javascript
    // server.js
    const express = require('express');
    const jwt = require('jsonwebtoken'); // Example: using jsonwebtoken library
    const { Pool } = require('pg');
    
    const app = express();
    const port = 3000;
    const JWT_SECRET = 'your-super-secret-key'; // Store this securely!
    
    const pool = new Pool({
        user: 'app_user',
        host: 'localhost',
        database: 'saas_db',
        password: 'password',
        port: 5432,
    });
    
    // Middleware to set the RLS context
    async function setRlsContext(req, res, next) {
        const token = req.headers.authorization?.split(' ')[1];
        if (!token) {
            return res.status(401).send('Unauthorized: No token provided');
        }
    
        let claims;
        try {
            claims = jwt.verify(token, JWT_SECRET);
        } catch (err) {
            return res.status(401).send('Unauthorized: Invalid token');
        }
    
        // Store claims for the request lifecycle
        req.userClaims = claims;
        next();
    }
    
    // A helper to wrap routes in a transaction and set the RLS context
    function withDbTransaction(handler) {
        return async (req, res) => {
            const client = await pool.connect();
            try {
                await client.query('BEGIN');
    
                // CRITICAL: Set the claims for the current transaction
                // We must stringify the JSON object to pass it as a string setting.
                const claimsJson = JSON.stringify(req.userClaims);
                await client.query(`SET LOCAL app.jwt.claims = '${claimsJson}'`);
    
                // Execute the actual route handler logic
                await handler(req, res, client);
    
                await client.query('COMMIT');
            } catch (err) {
                await client.query('ROLLBACK');
                console.error('Transaction Error:', err);
                res.status(500).send('Internal Server Error');
            } finally {
                client.release();
            }
        };
    }
    
    // Apply middleware to all protected routes
    app.use('/api', setRlsContext);
    
    // API endpoint to get projects
    app.get('/api/projects', withDbTransaction(async (req, res, dbClient) => {
        // The application code is now beautifully simple.
        // No `WHERE tenant_id = ?` needed here!
        const { rows } = await dbClient.query('SELECT id, name, status FROM projects');
        res.json(rows);
    }));
    
    app.listen(port, () => {
        console.log(`Server running on http://localhost:${port}`);
    });

    The most important line is SET LOCAL app.jwt.claims = '...'. Why LOCAL? In a production environment, your application uses a connection pool. A single database connection is reused by many different incoming user requests. If you were to use SET (without LOCAL), the setting would persist on that connection even after your transaction ends. The next user request that happens to receive that same connection from the pool would inherit the previous user's JWT claims, leading to a massive security vulnerability. SET LOCAL ensures the setting is automatically discarded at COMMIT or ROLLBACK.

    Advanced Scenarios & Performance Deep Dive

    Simple tenant isolation is a great start, but real-world systems are more complex.

    1. Handling Super-Admins and Support Roles

    What about a super-admin user who needs to see data across all tenants? We can embed a special claim in their JWT and update our policy to recognize it.

    Let's assume a super-admin JWT looks like this:

    json
    {
      "sub": "super-admin-001",
      "is_super_admin": true,
      "exp": 1678886400
    }

    We can update our helper function and policy:

    sql
    -- New helper function to check for super-admin status
    CREATE OR REPLACE FUNCTION is_current_user_super_admin() RETURNS BOOLEAN AS $$
    BEGIN
        RETURN COALESCE((current_setting('app.jwt.claims', true)::jsonb ->> 'is_super_admin')::boolean, false);
    EXCEPTION
        WHEN OTHERS THEN
            RETURN false;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- Now, drop the old policy and create a more advanced one
    DROP POLICY tenant_isolation_policy ON projects;
    
    CREATE POLICY advanced_tenant_isolation_policy ON projects
        FOR ALL
        USING (
            is_current_user_super_admin() OR tenant_id = get_current_tenant_id()
        )
        WITH CHECK (
            -- For inserts/updates, a super-admin might still need to specify a tenant.
            -- We don't want them to accidentally create tenant-less records.
            -- A non-admin is still locked to their tenant.
            is_current_user_super_admin() OR tenant_id = get_current_tenant_id()
        );

    Now, a request with a super-admin JWT will bypass the tenant_id check in the USING clause, allowing them to SELECT all projects. The WITH CHECK clause remains strict to ensure data integrity.

    2. Intra-Tenant Roles (Editors vs. Viewers)

    We can extend the same pattern for roles within a tenant. Let's say we want to restrict DELETE operations to users with an 'admin' or 'editor' role.

    Assume a JWT with a role claim:

    { "tenant_id": "acme-corp-456", "role": "viewer" }

    We can split our FOR ALL policy into more granular policies for specific commands.

    sql
    -- Helper to get the current role
    CREATE OR REPLACE FUNCTION get_current_user_role() RETURNS TEXT AS $$
    BEGIN
        RETURN current_setting('app.jwt.claims', true)::jsonb ->> 'role';
    EXCEPTION
        WHEN OTHERS THEN
            RETURN NULL;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- First, drop the generic policy
    DROP POLICY IF EXISTS advanced_tenant_isolation_policy ON projects;
    
    -- A permissive policy for SELECT for any user in the tenant
    CREATE POLICY tenant_select_policy ON projects
        FOR SELECT
        USING (tenant_id = get_current_tenant_id());
    
    -- A policy for INSERT/UPDATE for editors and admins
    CREATE POLICY tenant_modify_policy ON projects
        FOR INSERT, UPDATE
        USING (tenant_id = get_current_tenant_id())
        WITH CHECK (
            tenant_id = get_current_tenant_id() AND
            get_current_user_role() IN ('editor', 'admin')
        );
    
    -- A restrictive policy for DELETE, only for admins
    CREATE POLICY tenant_delete_policy ON projects
        FOR DELETE
        USING (
            tenant_id = get_current_tenant_id() AND
            get_current_user_role() = 'admin'
        );

    This demonstrates the true power of RLS: encoding complex business authorization rules directly alongside the data itself.

    3. Performance Analysis and Optimization

    RLS is not free. Every query on an RLS-enabled table incurs the overhead of executing the policy functions. For this architecture to be viable, it must be fast.

    * Function Volatility: Notice our helper functions are marked STABLE. This tells the PostgreSQL query planner that the function will return the same result for all rows within a single statement. This allows the planner to evaluate get_current_tenant_id() once per query, not once per row, which is a massive performance win.

    * Indexing: The tenant_id column is now part of the WHERE clause on every single query. It is absolutely critical that this column is indexed. Without an index, every query would result in a full table scan, destroying performance.

    CREATE INDEX ON projects (tenant_id);

    * Analyzing Query Plans: Use EXPLAIN ANALYZE to inspect the query plan and verify that RLS is working as expected and that your indexes are being used. Let's see it in action.

    sql
        -- In a psql session
        BEGIN;
        SET LOCAL app.jwt.claims = '{"tenant_id": "your-tenant-uuid-here"}';
        EXPLAIN ANALYZE SELECT * FROM projects WHERE status = 'active';
        ROLLBACK;

    You should see a query plan that looks something like this:

    text
                                                                  QUERY PLAN
        ----------------------------------------------------------------------------------------------------------------------------------
         Bitmap Heap Scan on projects  (cost=12.54..34.13 rows=10 width=69) (actual time=0.045..0.046 rows=5 loops=1)
           Recheck Cond: (status = 'active'::text)
           Filter: (tenant_id = 'your-tenant-uuid-here'::uuid) -- <-- RLS policy applied!
           ->  Bitmap Index Scan on projects_status_idx  (cost=0.00..12.53 rows=100 width=0) (actual time=0.039..0.039 rows=50 loops=1)
                 Index Cond: (status = 'active'::text)
         Planning Time: 0.150 ms
         Execution Time: 0.075 ms

    The Filter line is proof that the RLS policy was successfully and automatically applied by the database.

    Testing Strategies for RLS

    Since security logic now lives in the database, your testing strategy must adapt.

  • SQL-Level Unit Tests: Use a framework like pgTAP to write unit tests for your RLS policies directly in SQL. These tests can execute within a transaction, set various app.jwt.claims values (valid tenant, invalid tenant, super-admin, different roles), and assert that queries return the expected number of rows.
  • Integration Tests: Your application-level integration tests are still vital. These tests should cover the full lifecycle. They will need to:
  • * Generate different types of JWTs for various user personas.

    * Call the API endpoints with these tokens.

    * Assert that the API returns the correct data subset for a given tenant, or a 403 Forbidden for unauthorized actions (e.g., a 'viewer' trying to DELETE).

    * Test edge cases like malformed JWTs or missing claims.

    Conclusion: A Paradigm Shift in SaaS Security

    Moving multi-tenant authorization logic from the application layer to PostgreSQL via Row-Level Security is more than just a technical pattern; it's a paradigm shift. It replaces a distributed, error-prone system of WHERE clauses with a centralized, auditable, and robust security model that lives with the data itself.

    While it introduces new concepts like transactional session settings and requires a deeper understanding of PostgreSQL's execution model, the benefits are immense. Your application code becomes simpler and cleaner, focusing solely on business logic. Your security posture becomes stronger, as the risk of accidental data leakage through a missing code check is virtually eliminated. For senior engineers building the next generation of secure, scalable SaaS platforms, mastering the RLS-with-JWTs pattern isn't just an option—it's a necessity.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles