Securing Multi-Tenant APIs with PostgreSQL RLS and JWT Claims

17 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 Fragility of Application-Layer Authorization

In the world of multi-tenant SaaS applications, data isolation is not just a feature; it's a foundational security requirement. The most common pattern to achieve this is diligent, manual, and ultimately fragile: application-layer filtering. Every database query is meticulously crafted with a WHERE tenant_id = ? clause, typically after a middleware function verifies a user's session and attaches their tenant_id to the request object.

While this approach works, it carries significant technical debt and risk:

  • Human Error: A single forgotten WHERE clause in a complex JOIN or a hastily written reporting query can lead to a catastrophic data leak across tenants. This risk scales with team size and codebase complexity.
  • Code Duplication: The same authorization logic is repeated in countless controllers, services, and data access methods. While frameworks can abstract this, the core dependency on the developer remembering to use the abstraction remains.
  • Inconsistent Enforcement: As a system evolves into a microservices architecture, ensuring that every single service correctly and consistently implements tenant isolation becomes a major challenge. A single weak link compromises the entire system.
  • Bypassing the Application Layer: Any direct database access, whether by a support tool, a data analytics script, or a developer with production credentials, completely bypasses these application-layer checks. The security is tied to the application, not the data itself.
  • For senior engineers, these are not hypothetical problems. They are the latent bugs and security vulnerabilities we're paid to anticipate and prevent. The core issue is that we're making the application the sole gatekeeper to the data. A more robust architecture enforces security at the layer closest to the data: the database itself.

    The Architectural Shift: Data-Layer Enforcement with RLS and JWTs

    This is where PostgreSQL's Row-Level Security (RLS) becomes a game-changer. RLS allows you to define policies directly on a table, which act as an automatic, implicit WHERE clause for any query executed against that table. The database, not the application, becomes the ultimate enforcer of data access rules.

    Our advanced pattern combines this powerful database feature with a standard of modern authentication: JSON Web Tokens (JWTs). The JWT, issued upon successful authentication, will carry the necessary authorization context (like tenant_id and role) as claims. We will then securely propagate this context from the application to the database for every transaction.

    The high-level data flow looks like this:

  • Authentication: A user authenticates and receives a JWT containing claims such as user_id, role, and tenant_id.
  • API Request: The client sends a request to the API, including the JWT in the Authorization header.
  • Middleware Context Propagation: A middleware layer on the API server verifies the JWT. Upon successful verification, it extracts the claims.
  • Secure Session Setup: Instead of just attaching the claims to a request object, the middleware executes a SQL statement to set session-local configuration variables within the current database connection. For example: SET LOCAL a.custom.claim.tenant_id = 'the-tenant-id-from-jwt';.
  • Simplified Application Logic: The application's business logic now executes simple, tenant-agnostic queries like SELECT * FROM projects;.
  • RLS Policy Enforcement: PostgreSQL intercepts this query. Before executing it, it applies the pre-defined RLS policy on the projects table. This policy reads the session-local variable (a.custom.claim.tenant_id) and dynamically appends the filtering logic, effectively transforming the query into SELECT * FROM projects WHERE tenant_id = 'the-tenant-id-from-jwt';.
  • This is a paradigm shift. The application code becomes cleaner and less error-prone, as it no longer needs to concern itself with tenant isolation. Security is centralized, auditable, and impossible to bypass, even with direct database access (for non-superusers).

    Deep Dive: A Production-Grade Implementation

    Let's build this system from the ground up. We'll use a Node.js/Express API with the pg library as our example, but the principles are transferable to any language or framework.

    1. Database Schema and RLS Setup

    First, let's define a simple multi-tenant schema.

    sql
    -- Create tenants and users tables
    CREATE TABLE tenants (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name TEXT NOT NULL
    );
    
    CREATE TABLE users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        tenant_id UUID NOT NULL REFERENCES tenants(id),
        email TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'member' -- e.g., 'member', 'admin'
    );
    
    -- The resource table we want to protect
    CREATE TABLE projects (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        tenant_id UUID NOT NULL REFERENCES tenants(id),
        name TEXT NOT NULL,
        created_by UUID NOT NULL REFERENCES users(id)
    );
    
    -- Crucial: Add indexes for performance
    CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
    CREATE INDEX idx_users_tenant_id ON users(tenant_id);

    Now, let's enable RLS on the projects table and create our first policy.

    sql
    -- Step 1: Enable RLS on the target table.
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    
    -- Step 2: Create a policy that enforces tenant isolation.
    -- This policy applies to ALL commands (SELECT, INSERT, UPDATE, DELETE).
    CREATE POLICY tenant_isolation_policy ON projects
    FOR ALL
    USING (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid)
    WITH CHECK (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid);

    Let's break down this policy definition:

    * ALTER TABLE projects ENABLE ROW LEVEL SECURITY;: This command activates RLS for the table. By default, this also creates a deny all policy, meaning no rows will be returned until a permissive policy is added.

    * CREATE POLICY tenant_isolation_policy ON projects: We're defining a new policy named tenant_isolation_policy on the projects table.

    * FOR ALL: This policy applies to SELECT, INSERT, UPDATE, and DELETE commands.

    * USING (...): This clause is for reads (SELECT). A row is visible only if this expression evaluates to true.

    * WITH CHECK (...): This clause is for writes (INSERT, UPDATE). A write operation is allowed only if this expression evaluates to true for the new or updated row. This is a critical security feature that prevents a user from one tenant from inserting or moving data into another tenant's scope.

    * current_setting('a.custom.claim.tenant_id', true)::uuid: This is the core of our dynamic system. The current_setting() function retrieves a runtime configuration parameter. We've namespaced our parameter with a.custom.claim. to avoid conflicts with standard PostgreSQL settings. The second argument, true, tells the function not to error if the setting is missing (it will return an empty string). We then cast the result to uuid.

    2. JWT Generation with Custom Claims

    When a user logs in, we need to generate a JWT that contains the necessary claims for our RLS policies.

    javascript
    // Node.js example using 'jsonwebtoken'
    const jwt = require('jsonwebtoken');
    
    // Assume 'user' is an object retrieved from the database after a successful login
    // user = { id: 'user-uuid', tenant_id: 'tenant-uuid', role: 'admin' }
    
    function generateUserToken(user) {
        const payload = {
            sub: user.id, // Standard 'subject' claim
            // Custom claims namespaced for our application
            'https://myapp.com/claims': {
                tenant_id: user.tenant_id,
                user_id: user.id,
                role: user.role
            }
        };
    
        const options = {
            expiresIn: '8h',
            issuer: 'https://api.myapp.com',
            audience: 'https://myapp.com'
        };
    
        const secret = process.env.JWT_SECRET;
        return jwt.sign(payload, secret, options);
    }

    3. The API Middleware: Propagating JWT Context to the Database

    This is the most critical piece of application code. The middleware needs to verify the JWT and then set the session variables on the database connection before passing control to the route handler. This ensures that every query within that request's lifecycle is correctly scoped.

    javascript
    // Node.js/Express middleware using 'pg' pool
    const { Pool } = require('pg');
    const jwt = require('jsonwebtoken');
    
    const pool = new Pool(); // Configure with your DB credentials
    
    async function authAndSetRLS(req, res, next) {
        const token = req.headers.authorization?.split(' ')[1];
    
        if (!token) {
            return res.status(401).send('Unauthorized: No token provided.');
        }
    
        let claims;
        try {
            const decoded = jwt.verify(token, process.env.JWT_SECRET);
            claims = decoded['https://myapp.com/claims'];
            if (!claims || !claims.tenant_id || !claims.user_id) {
                throw new Error('Invalid claims');
            }
        } catch (err) {
            return res.status(401).send('Unauthorized: Invalid token.');
        }
    
        // IMPORTANT: We must use a single client for the entire transaction.
        // We attach it to the request object to be used by subsequent handlers.
        const client = await pool.connect();
        req.dbClient = client;
    
        try {
            // Use SET LOCAL to scope the settings to the current transaction only.
            // This is VITAL for preventing state leakage in a connection pool.
            await client.query('BEGIN'); // Start a transaction
            
            // Using parameterized queries to prevent SQL injection
            await client.query(`SET LOCAL a.custom.claim.tenant_id = $1`, [claims.tenant_id]);
            await client.query(`SET LOCAL a.custom.claim.user_id = $1`, [claims.user_id]);
            await client.query(`SET LOCAL a.custom.claim.role = $1`, [claims.role]);
    
            // The claims are now set for the duration of this transaction.
            // We can also attach them to the request for application-level logic if needed.
            req.userClaims = claims;
    
            // Listen for the response to finish to commit/rollback and release the client
            res.on('finish', async () => {
                try {
                    // The 'error' event is emitted if the headers were sent but the request failed after.
                    if (!res.headersSent || res.statusCode >= 400) {
                         await client.query('ROLLBACK');
                    } else {
                         await client.query('COMMIT');
                    }
                } finally {
                    client.release();
                }
            });
            
            next();
        } catch (err) {
            // If setting RLS fails, rollback and release the client immediately
            await client.query('ROLLBACK');
            client.release();
            res.status(500).send('Failed to set security context.');
        }
    }

    With this middleware in place, our route handlers become beautifully simple:

    javascript
    app.get('/projects', authAndSetRLS, async (req, res) => {
        try {
            // The dbClient is the same connection where RLS settings were applied.
            const result = await req.dbClient.query('SELECT id, name FROM projects');
            res.json(result.rows);
        } catch (err) {
            // The 'finish' event handler will still fire to rollback and release the client.
            console.error(err);
            res.status(500).send('Error fetching projects.');
        }
    });

    The SELECT query has no WHERE tenant_id clause. It doesn't need one. PostgreSQL handles the security transparently.

    Advanced Scenarios and Edge Cases

    Real-world systems are more complex than simple tenant isolation. Here’s how to extend this pattern.

    1. Role-Based Access Control (RBAC)

    What if a tenant admin can see all projects in their tenant, but a member can only see projects they created? We can create multiple policies. PostgreSQL combines permissive policies with a logical OR.

    First, we drop the old policy and create two new, more specific ones.

    sql
    DROP POLICY tenant_isolation_policy ON projects;
    
    -- Policy for admins: can see any project within their tenant.
    CREATE POLICY admin_access_policy ON projects
    FOR SELECT
    USING (
        current_setting('a.custom.claim.role', true) = 'admin' AND
        tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid
    );
    
    -- Policy for members: can only see projects they created within their tenant.
    CREATE POLICY member_access_policy ON projects
    FOR SELECT
    USING (
        current_setting('a.custom.claim.role', true) = 'member' AND
        created_by = current_setting('a.custom.claim.user_id', true)::uuid AND
        tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid
    );
    
    -- Don't forget the write policy! This one can remain simple.
    CREATE POLICY write_policy ON projects
    FOR INSERT, UPDATE, DELETE
    USING (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid)
    WITH CHECK (tenant_id = current_setting('a.custom.claim.tenant_id', true)::uuid);

    Now, when a request comes in, PostgreSQL will evaluate both admin_access_policy and member_access_policy. If the user's JWT has role: 'admin', the first policy will match. If it has role: 'member', the second will match. The security logic is co-located with the data and remains invisible to the application.

    2. Handling Superusers and Internal Tools

    By default, RLS policies do not apply to table owners or superusers. This is often desirable for database maintenance. However, for internal admin panels or support tools, you might want to enforce RLS even for privileged roles. You can do this with FORCE.

    sql
    ALTER TABLE projects FORCE ROW LEVEL SECURITY;

    This command ensures that RLS policies are applied to all roles, including the table owner. To grant bypass access, you would typically create a specific role for that purpose and grant it the BYPASS RLS attribute.

    sql
    CREATE ROLE internal_tools_role WITH BYPASS RLS;
    -- Grant this role to the database user for your internal tool.

    3. Cross-Tenant Data Access for Privileged Users

    Imagine a scenario where a global support user needs to access data for a specific set of tenants they manage. We can't use a single tenant_id claim. Instead, we can pass an array of tenant IDs in the JWT.

    JWT Payload:

    json
    {
      "https://myapp.com/claims": {
        "user_id": "support-user-uuid",
        "role": "support_agent",
        "allowed_tenants": ["tenant-uuid-1", "tenant-uuid-2"]
      }
    }

    Middleware: The middleware would serialize this array into a string for the session variable.

    javascript
    // In authAndSetRLS middleware
    const allowedTenants = claims.allowed_tenants || [];
    await client.query(`SET LOCAL a.custom.claim.allowed_tenants = $1`, [allowedTenants.join(',')]);

    RLS Policy: The policy uses PostgreSQL's array functions to check for membership.

    sql
    CREATE POLICY support_agent_access_policy ON projects
    FOR SELECT
    USING (
        current_setting('a.custom.claim.role', true) = 'support_agent' AND
        tenant_id = ANY(string_to_array(current_setting('a.custom.claim.allowed_tenants', true), ',')::uuid[])
    );

    This powerful pattern allows for highly granular, dynamic access control defined entirely by the JWT's contents, without changing application code.

    Performance Considerations and Benchmarking

    Moving logic into the database always raises performance questions. Fortunately, PostgreSQL's implementation of RLS is highly optimized.

  • current_setting() Overhead: The current_setting() function is extremely fast. It performs an in-memory lookup of the session's configuration parameters. Its overhead is negligible compared to the cost of the query itself.
  • Query Planning: The most important consideration is how RLS affects the query planner. Does it fetch all data and then filter, or does it incorporate the policy into the plan? We can verify this with EXPLAIN ANALYZE.
  • Let's assume we've set a.custom.claim.tenant_id to a specific UUID in our session.

    sql
    EXPLAIN ANALYZE SELECT * FROM projects WHERE name LIKE 'Feature%';

    The Output Will Look Something Like This:

    text
    Bitmap Heap Scan on projects  (cost=...)
      Recheck Cond: (name ~~ 'Feature%'::text)
      Filter: (tenant_id = '...'::uuid) -- <-- RLS POLICY APPLIED HERE!
      ->  Bitmap Index Scan on idx_projects_name  (cost=...)
            Index Cond: (name ~~ 'Feature%'::text)

    Crucially, the Filter: (tenant_id = '...'::uuid) is part of the execution plan. PostgreSQL is smart enough to combine the RLS policy with the explicit query predicates before execution. This means it can use indexes on tenant_id just as effectively as if you had written the WHERE clause yourself. Your existing indexing strategies on foreign keys like tenant_id will work perfectly with RLS.

  • Connection Pooling (SET LOCAL vs. SET): This is a critical production detail. When using a connection pooler like PgBouncer, connections are reused across different users and requests.
  • * Using SET would modify the setting for the entire session, potentially leaking one user's tenant_id to another user's request that happens to receive the same pooled connection. This is a severe security flaw.

    Using SET LOCAL scopes the setting to the current transaction only*. Once the transaction is committed or rolled back, the setting reverts to its previous value. This makes it safe to use with transaction-level connection pooling, as each request is wrapped in its own transaction, gets its own local settings, and leaves the connection clean for the next request.

    Trade-offs and When Not to Use This Pattern

    This architecture is powerful, but it's not a silver bullet. Consider the following trade-offs:

    * Increased Database Complexity: Your authorization logic now lives in SQL. This can be less familiar to application developers, and debugging can involve inspecting database policies rather than application code. It requires your team to be comfortable with advanced database features.

    * Vendor Lock-in: This is a PostgreSQL-specific solution. If you ever plan to migrate to MySQL or another database, you will need to completely re-implement this authorization logic in your application layer.

    * Testing: Unit and integration testing strategies need to adapt. Your tests must now be able to set the appropriate database session variables to simulate requests from different users and tenants. This often involves creating dedicated test helper functions that wrap test queries within a transaction that sets the RLS context.

    * Business Logic vs. Data Access: RLS is best suited for coarse-grained, data-access-level authorization (e.g., "Can this user see this row?"). It is not a replacement for fine-grained business logic authorization (e.g., "Can a user on the 'free' plan create a sixth project?"). That logic still belongs in the application layer.

    Conclusion: A New Foundation for Secure Systems

    By moving tenant isolation and role-based data access from the application layer to the database layer, we fundamentally harden our system's security posture. This pattern creates a single, auditable source of truth for authorization that is enforced for every query, regardless of its origin.

    The application code is simplified, reducing the surface area for human error and making developers more productive. While it requires a deeper understanding of PostgreSQL's capabilities and introduces new testing considerations, the payoff is a zero-trust data access model that is more robust, scalable, and secure.

    For senior engineers building the next generation of multi-tenant applications, leveraging PostgreSQL RLS with JWT claims isn't just an interesting technique—it's a superior architectural pattern for achieving data security by design.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles