PostgreSQL RLS with JWTs for Granular Multi-Tenant Isolation

16 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 Flaw in Application-Layer Tenancy Enforcement

In most multi-tenant architectures, data isolation is an application-layer concern. A typical request flow involves validating a user's session, identifying their tenant ID, and then manually appending WHERE organization_id = ? to every single database query. While functional, this pattern is fundamentally fragile. A single missing WHERE clause in a complex query, a forgotten check in a new developer's code, or a subtle bug in a shared data access layer can lead to catastrophic data leakage between tenants.

This approach violates the principle of defense-in-depth. The database, the ultimate guardian of the data, is blindly trusted to execute whatever queries the application sends. For systems where data segregation is a core security and compliance requirement, this is an unacceptable risk.

The solution is to delegate tenancy enforcement to the database itself. PostgreSQL's Row-Level Security (RLS) is a powerful feature designed for exactly this purpose. It allows us to define fine-grained access policies directly on tables, ensuring that no matter how a query is constructed, the database will transparently and mandatorily enforce isolation rules. The challenge, however, lies in securely and efficiently communicating the application user's context—their identity and tenant affiliation—to PostgreSQL. This is where JSON Web Tokens (JWTs) become the critical link.

This article details a production-grade pattern for integrating JWT-based authentication with PostgreSQL RLS to create a virtually impenetrable data isolation layer. We will not cover the basics of RLS or JWTs; we assume you are a senior engineer familiar with these concepts. Instead, we focus on the advanced implementation details, performance tuning, and security gotchas that arise when deploying this architecture at scale.

Architecture Overview: Propagating JWT Claims to the Database Session

The core of this architecture is a mechanism to pass claims from a validated JWT into the PostgreSQL session context for the duration of a single request. We can't pass the JWT directly into every query; that would be inefficient and cumbersome. Instead, we use a session-local configuration parameter.

Here's the flow for a typical API request:

  • Client Request: The client sends a request with an Authorization: Bearer header.
  • API Middleware: A middleware layer in your application (e.g., Express, Koa, NestJS) intercepts the request.
  • JWT Validation: The middleware validates the JWT signature and expiry.
  • Database Connection: It acquires a connection from a connection pool.
  • Context Propagation: The middleware executes a SET LOCAL command on the database connection, creating a temporary, transaction-scoped variable. We'll store critical JWT claims like tenant_id, user_id, and roles here.
  • sql
        -- Example of setting session context
        SET LOCAL "request.jwt.claims.tenant_id" = '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d';
        SET LOCAL "request.jwt.claims.user_id" = 'user-abc-123';
  • Business Logic: The application's business logic runs. All subsequent queries on this connection during this transaction will have access to these session variables via the current_setting() function.
  • RLS Policy Enforcement: PostgreSQL RLS policies on the queried tables read these session variables to filter the data, transparently adding the WHERE clauses.
  • Connection Release: When the request is complete, the database connection is returned to the pool. The SET LOCAL configuration is automatically discarded, ensuring the next use of that connection is clean.
  • This pattern is highly secure because the claims are set by a trusted backend process after JWT validation, and the settings are confined to a single transaction, preventing context leakage between requests.

    Database Schema Foundation

    Let's establish a simple but realistic schema for our multi-tenant SaaS application.

    sql
    -- Create organizations (tenants)
    CREATE TABLE organizations (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name TEXT NOT NULL
    );
    
    -- Create users, belonging to an organization
    CREATE TABLE users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
        email TEXT NOT NULL UNIQUE,
        -- other user fields...
    );
    
    -- Create a resource table, e.g., projects
    CREATE TABLE projects (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
        owner_id UUID NOT NULL REFERENCES users(id),
        name TEXT NOT NULL,
        created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    
    -- CRITICAL: Create indexes on the tenancy key for performance
    CREATE INDEX idx_users_organization_id ON users(organization_id);
    CREATE INDEX idx_projects_organization_id ON projects(organization_id);

    Implementing the Core RLS Policy

    With the schema in place, we can now define our RLS policies. The first step is to enable RLS on the target table. This is a commonly missed step; without it, your policies will not be enforced.

    sql
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

    Now, we create the policy. Our policy will ensure that users can only interact with projects belonging to their own organization. It will use current_setting('request.jwt.claims.tenant_id', true) to read the tenant ID we set in our middleware. The second argument true tells PostgreSQL not to error if the setting is missing, but to return NULL instead, which is a safer default.

    sql
    CREATE POLICY tenant_isolation_policy ON projects
    AS PERMISSIVE -- Can be PERMISSIVE or RESTRICTIVE
    FOR ALL -- Applies to SELECT, INSERT, UPDATE, DELETE
    TO authenticated_user -- A specific database role for your application
    USING (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
    )
    WITH CHECK (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
    );

    Let's break this down:

    * PERMISSIVE: Multiple PERMISSIVE policies are combined with a logical OR. If any PERMISSIVE policy for a given command evaluates to true, access is granted.

    * RESTRICTIVE: Multiple RESTRICTIVE policies are combined with AND. All RESTRICTIVE policies must pass. This is useful for global deny rules.

    * FOR ALL: A shorthand for applying the policy to SELECT, INSERT, UPDATE, and DELETE operations.

    * TO authenticated_user: This policy only applies when the database session is running under the authenticated_user role. Your application should connect to the database using this specific, non-superuser role.

    * USING (...): This clause is evaluated for existing rows. It applies to SELECT, UPDATE, and DELETE. If the expression returns true, the row is visible/modifiable. Effectively, PostgreSQL adds this as a WHERE clause.

    * WITH CHECK (...): This clause is evaluated for new or updated rows. It applies to INSERT and UPDATE. If the expression returns true, the operation is allowed. This prevents a user from one tenant from inserting a project with another tenant's organization_id.

    Production-Grade Middleware Implementation (Node.js/Express)

    Here is a complete, production-ready example of an Express middleware that bridges the JWT and the database session. It uses the pg library.

    javascript
    // lib/db.js
    const { Pool } = require('pg');
    
    // It's critical to use a connection pool
    const pool = new Pool({
      // your connection config
      user: 'authenticated_user', // Connect as the non-superuser role
      // ... other settings
    });
    
    module.exports = { pool };
    
    // lib/auth.js
    const jwt = require('jsonwebtoken');
    const { pool } = require('./db');
    
    const JWT_SECRET = process.env.JWT_SECRET;
    
    // This middleware injects RLS context into the database session
    const injectRlsContext = async (req, res, next) => {
      const authHeader = req.headers.authorization;
    
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).send('Unauthorized: No token provided');
      }
    
      const token = authHeader.split(' ')[1];
      let claims;
    
      try {
        claims = jwt.verify(token, JWT_SECRET);
      } catch (err) {
        return res.status(401).send('Unauthorized: Invalid token');
      }
    
      // We need a database client for this specific request
      // We attach it to the request object to be used by route handlers
      req.dbClient = await pool.connect();
    
      try {
        // Use a transaction block to ensure SET LOCAL is scoped correctly
        await req.dbClient.query('BEGIN');
    
        // Securely set the session variables. Use pg's parameterized queries to prevent SQL injection.
        // Note: Parameterizing the setting name itself isn't supported, so they are hardcoded.
        await req.dbClient.query(`SET LOCAL "request.jwt.claims.tenant_id" = $1`, [claims.tenantId]);
        await req.dbClient.query(`SET LOCAL "request.jwt.claims.user_id" = $1`, [claims.userId]);
        // You can also stringify a JSON object for more complex claims like roles
        await req.dbClient.query(`SET LOCAL "request.jwt.claims.roles" = $1`, [JSON.stringify(claims.roles || [])]);
    
        // Add a cleanup hook for when the request finishes
        res.on('finish', async () => {
          try {
            // Commit if the response was successful
            if (res.statusCode < 400) {
              await req.dbClient.query('COMMIT');
            } else {
              // Rollback on errors
              await req.dbClient.query('ROLLBACK');
            }
          } finally {
            // ALWAYS release the client back to the pool
            req.dbClient.release();
          }
        });
    
        next();
    
      } catch (err) {
        // If setting context fails, rollback and release the client
        await req.dbClient.query('ROLLBACK');
        req.dbClient.release();
        console.error('Failed to set RLS context:', err);
        res.status(500).send('Internal Server Error');
      }
    };
    
    module.exports = { injectRlsContext };
    
    // server.js
    const express = require('express');
    const { injectRlsContext } = require('./lib/auth');
    const app = express();
    
    // Apply the middleware to all protected routes
    app.use('/api/v1', injectRlsContext);
    
    app.get('/api/v1/projects', async (req, res) => {
      try {
        // The req.dbClient is available from our middleware
        // We don't need a WHERE clause here! RLS handles it.
        const { rows } = await req.dbClient.query('SELECT id, name FROM projects');
        res.json(rows);
      } catch (err) {
        console.error(err);
        res.status(500).send('Error fetching projects');
      }
    });
    
    app.listen(3000, () => console.log('Server running on port 3000'));

    Notice the beauty of the route handler for /api/v1/projects. It's a simple SELECT * FROM projects. The business logic is now blissfully unaware of multi-tenancy, making the code cleaner, less error-prone, and easier to maintain.

    Advanced Scenario: Role-Based Access Control (RBAC)

    The basic tenant isolation is powerful, but real-world applications require more granular permissions. Let's extend our JWT to include roles and create policies that use them.

    Assume our JWT payload now looks like this:

    json
    {
      "userId": "user-abc-123",
      "tenantId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
      "roles": ["member", "project_admin"]
    }

    We want a policy where project_admin can see all projects in the organization, but a member can only see projects they own.

    First, we'll update our existing policy to be the base case for admins and create a new, more restrictive policy for members.

    sql
    -- Helper function to check for a role in the JSON array from our session context
    -- This function is STABLE, meaning its result depends only on its arguments and database state,
    -- but can change within a transaction. This is appropriate for current_setting().
    CREATE OR REPLACE FUNCTION has_role(role_name TEXT) RETURNS BOOLEAN AS $$
    BEGIN
        -- The 'true' flag prevents an error if the setting doesn't exist
        RETURN (current_setting('request.jwt.claims.roles', true)::jsonb) ? role_name;
    EXCEPTION
        -- Handle cases where the JSON is invalid or the setting is missing
        WHEN others THEN
            RETURN FALSE;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- First, let's redefine the original policy to only be for admins.
    DROP POLICY IF EXISTS tenant_isolation_policy ON projects;
    
    CREATE POLICY admin_full_access_policy ON projects
    AS PERMISSIVE
    FOR ALL
    TO authenticated_user
    USING (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
        AND has_role('project_admin')
    )
    WITH CHECK (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
        AND has_role('project_admin')
    );
    
    -- Now, create a second PERMISSIVE policy for members.
    -- Since policies are combined with OR, a user will get access if they are
    -- an admin OR they are a member and own the project.
    CREATE POLICY member_owner_access_policy ON projects
    AS PERMISSIVE
    FOR SELECT, UPDATE -- Members can only see/update their own projects
    TO authenticated_user
    USING (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
        AND owner_id = current_setting('request.jwt.claims.user_id')::uuid
        AND has_role('member')
    );
    
    -- A member should also be able to create projects for their organization
    CREATE POLICY member_insert_access_policy ON projects
    AS PERMISSIVE
    FOR INSERT
    TO authenticated_user
    WITH CHECK (
        organization_id = current_setting('request.jwt.claims.tenant_id')::uuid
        AND owner_id = current_setting('request.jwt.claims.user_id')::uuid -- They must be the owner
        AND has_role('member')
    );

    With these policies, a user with the project_admin role will pass the admin_full_access_policy and see all projects. A user with only the member role will fail the admin policy but pass the member_owner_access_policy for rows where owner_id matches their user ID.

    Performance Considerations and Query Analysis

    RLS is not magic. It adds predicates to your queries, and these predicates must be efficient. An unindexed RLS policy can destroy database performance.

    1. Indexing is Non-Negotiable:

    The most critical performance factor is ensuring that all columns used in your RLS policies are indexed. In our case, organization_id and owner_id in the projects table must have indexes.

    2. Analyzing Query Plans with EXPLAIN ANALYZE:

    Always verify that PostgreSQL is using an efficient plan. Let's analyze our simple SELECT query.

    sql
    -- In a psql session, first set the context variables to simulate an application request
    SET "request.jwt.claims.tenant_id" = '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d';
    SET "request.jwt.claims.user_id" = 'user-abc-123';
    SET "request.jwt.claims.roles" = '["member"]';
    
    EXPLAIN ANALYZE SELECT id, name FROM projects;

    The expected output for a member should show an efficient index scan:

    text
                                                             QUERY PLAN
    -----------------------------------------------------------------------------------------------------------------------------
     Index Scan using idx_projects_organization_id on projects  (cost=0.42..8.44 rows=1 width=52) (actual time=0.021..0.022 rows=5 loops=1)
       Index Cond: (organization_id = '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d'::uuid)
       Filter: ((owner_id = 'd8f8a8e8-8e8e-4f8e-8e8e-8e8e8e8e8e8e'::uuid) AND has_role('member'::text))
     Planning Time: 0.145 ms
     Execution Time: 0.041 ms

    Key takeaways from this plan:

    * Index Scan: Excellent. PostgreSQL is using our idx_projects_organization_id to quickly find the relevant tenant's data.

    * Index Cond: The tenant ID check is being applied at the index level, which is the most efficient way.

    * Filter: The owner_id and has_role checks are applied as a post-scan filter. This is still very fast because the index scan has already narrowed the dataset down to a single tenant's projects.

    If you see a Seq Scan (Sequential Scan) on a large table, it's a major red flag that your indexes are missing or not being used, and your RLS policy will be a performance bottleneck.

    Advanced Edge Cases and Security Gotchas

    `SECURITY DEFINER` vs. `SECURITY INVOKER` (The #1 Security Pitfall)

    PostgreSQL functions can be defined with SECURITY INVOKER (the default) or SECURITY DEFINER.

    SECURITY INVOKER: The function executes with the permissions of the role that calls* it. RLS policies are respected.

    SECURITY DEFINER: The function executes with the permissions of the role that owns* the function. If the owner is a superuser or a role with BYPASSRLS, RLS policies will be bypassed entirely.

    This is an extremely dangerous footgun. Imagine a seemingly innocuous function created by a superuser:

    sql
    -- DANGEROUS EXAMPLE - DO NOT USE
    CREATE OR REPLACE FUNCTION get_project_name(p_id UUID) RETURNS TEXT AS $$
    DECLARE
        project_name TEXT;
    BEGIN
        SELECT name INTO project_name FROM projects WHERE id = p_id;
        RETURN project_name;
    END;
    $$ LANGUAGE plpgsql SECURITY DEFINER;

    If a user from Tenant A knows the project ID of a project in Tenant B, they could call SELECT get_project_name('tenant-b-project-id'). Because the function is SECURITY DEFINER and owned by a superuser, the SELECT statement inside the function bypasses RLS, leaking data from Tenant B.

    Rule of thumb: Always use SECURITY INVOKER unless you have an explicit and well-understood reason to bypass RLS, and even then, proceed with extreme caution.

    Views and `security_barrier`

    RLS policies on underlying tables do not always apply to views in the way you might expect, especially if the view's WHERE clause can be pushed down by the query planner before the RLS policy is applied. This can create side-channel attacks where a user can infer data by observing which rows cause an error.

    To prevent this, use the security_barrier option on your views.

    sql
    CREATE VIEW user_projects AS
    SELECT id, name, owner_id FROM projects;
    
    ALTER VIEW user_projects SET (security_barrier = true);

    A security_barrier view enforces that the view's WHERE clauses are evaluated before any conditions from the user's query, and more importantly, RLS policies from the underlying tables are applied before any other conditions. This ensures that a user cannot craft a join or subquery that leaks information before the RLS policy has a chance to filter the rows.

    Bypassing RLS for Super Admins and Internal Processes

    Sometimes, you need to bypass RLS. A background worker processing data across all tenants or a super-admin support tool are common examples. The correct approach is to use a dedicated database role that has the BYPASSRLS attribute.

    sql
    CREATE ROLE internal_worker WITH LOGIN PASSWORD '...';
    ALTER ROLE internal_worker BYPASSRLS;

    Your internal services or background job processors would connect to the database using this internal_worker role. This connection string must be a closely guarded secret and should never be used by the public-facing API that handles user requests.

    Testing Strategies for RLS

    Testing RLS is critical. You cannot rely on simply inspecting the policies. You must write integration tests that prove isolation is being enforced.

    Using a framework like Jest, you can structure your tests as follows:

  • Setup: Before each test, seed a test database with data for at least two different tenants (Tenant A and Tenant B).
  • JWT Generation: Create a helper function to generate valid JWTs for different users (e.g., a user in Tenant A, an admin in Tenant A, a user in Tenant B).
  • Test Cases:
  • Test Isolation: Make a request as the Tenant A user and assert that you only* receive data from Tenant A. Assert that Tenant B's data is not present.

    * Test Role Permissions: Make a request as a member in Tenant A and assert you can only see projects you own. Then, make a request as an admin in Tenant A and assert you can see all projects in that tenant.

    * Test WITH CHECK: As a user from Tenant A, attempt to INSERT a project with organization_id of Tenant B. Assert that the request fails with a policy violation error.

    * Test Unauthenticated Access: Make a request without a JWT and assert that you receive a 401 Unauthorized and no data.

    This rigorous testing provides confidence that your data layer security is functioning as designed.

    Conclusion

    By pushing multi-tenancy enforcement into PostgreSQL with Row-Level Security, we build a far more robust and secure system. Application code is simplified and the risk of accidental data leakage through developer error is dramatically reduced. The combination of JWT claims propagated to the database session via SET LOCAL provides a seamless, performant, and highly secure bridge between the application and data layers.

    While the initial setup is more complex than simple WHERE clauses, the long-term benefits in security, maintainability, and peace of mind are immeasurable for any serious multi-tenant application. By understanding and correctly implementing advanced concepts like role-based policies, performance tuning, and security guardrails like SECURITY INVOKER and security_barrier, you can build a data isolation model that is truly production-grade.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles