PostgreSQL RLS with JWTs for Microservice Tenancy Control

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 Tenancy Conundrum in Microservices

In a monolithic architecture, managing user-specific data access is relatively straightforward. The application has a coherent session object, accessible throughout the codebase, containing the current user's ID, organization, and roles. Every database query can be systematically augmented with a WHERE organization_id = ? clause derived from this session context.

Microservices shatter this paradigm. An orders-service may receive a gRPC call from a frontend-api service, which in turn received a request from an API gateway. The orders-service is several hops away from the initial authentication event. It trusts the internal network but has no inherent knowledge of the end-user. The default, and dangerously naive, approach is to pass identity information (user_id, organization_id) as parameters in every internal API call and subsequently in every function call down to the data access layer.

This approach is fundamentally flawed for several reasons:

  • Brittleness: A single developer forgetting to add the WHERE clause to a new query introduces a critical data leak across tenants.
  • Lack of Centralization: Security logic is scattered across dozens or hundreds of functions in multiple services, making audits and updates a nightmare.
  • Untrustworthy Direct Access: It offers no protection against ad-hoc queries from developers, data analysts, or compromised internal tools that connect directly to the database.
  • We need a more robust solution that enforces tenancy at the lowest possible layer: the database itself. This is where PostgreSQL's Row-Level Security (RLS) becomes a powerful tool, but only if we can bridge the context gap between our stateless microservice and the stateful database session.

    This article presents a pattern for securely propagating JWT claims into the PostgreSQL session, allowing RLS policies to make dynamic, context-aware decisions.

    The Architecture: Propagating JWT Claims to the Database Session

    The core principle is to make the database aware of the identity of the user making a request. We achieve this by using the database's own session configuration as a temporary, request-scoped storage for JWT claims.

    Here's the flow:

  • Authentication: A user authenticates with an auth-service, which issues a signed JWT containing claims like sub (user ID), org_id, and role.
  • API Gateway: The gateway validates the JWT on every incoming request and forwards it to the appropriate downstream microservice (e.g., products-service) in an Authorization header.
  • Microservice Middleware: The products-service has a piece of middleware that runs at the beginning of every request. This middleware:
  • a. Validates the JWT signature and expiration again (zero-trust).

    b. Extracts the necessary claims.

    c. Obtains a database connection from its pool.

    d. Crucially, within a single database transaction, it sets session-local variables using SET LOCAL. For example: SET LOCAL "request.jwt.claims.org_id" = 'org_123';

  • Database Query: The service's business logic runs its standard SQL queries (e.g., SELECT * FROM products;) without any explicit WHERE organization_id = ? clauses.
  • PostgreSQL RLS Enforcement: Before executing the query, PostgreSQL evaluates the active RLS policies on the products table. These policies read the session variables (e.g., using current_setting('request.jwt.claims.org_id')) to filter the rows, effectively adding the WHERE clause at the database level.
  • Transaction Commit: The transaction is committed or rolled back. The session variables set with SET LOCAL are automatically discarded, ensuring the connection is clean when returned to the pool.
  • This pattern centralizes tenancy logic within the database schema, making it unbypassable and consistent across all services that interact with a given table.

    Step 1: Database Schema and RLS Foundation

    First, let's define a simple multi-tenant schema. We'll have organizations and products, where each product belongs to one organization.

    sql
    -- Create a sample organizations table
    CREATE TABLE organizations (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name TEXT NOT NULL
    );
    
    -- Create a products table with a foreign key to organizations
    CREATE TABLE products (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES organizations(id),
        name TEXT NOT NULL,
        price NUMERIC(10, 2) NOT NULL
    );
    
    -- Create an index on the tenancy column, as it will be used in every RLS check
    CREATE INDEX idx_products_organization_id ON products(organization_id);
    
    -- Insert some sample data for two different tenants
    INSERT INTO organizations (id, name) VALUES
        ('a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'Org A'),
        ('b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'Org B');
    
    INSERT INTO products (organization_id, name, price) VALUES
        ('a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'Widget A', 99.99),
        ('b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'Gadget B', 149.50);

    Now, we enable RLS on the products table. This is a critical step. Once enabled, the default behavior is to deny all access unless a specific policy grants it.

    sql
    -- Enable Row Level Security on the products table
    ALTER TABLE products ENABLE ROW LEVEL SECURITY;
    
    -- It's a best practice to also force RLS for the table owner.
    -- This prevents accidental bypass during migrations or administrative tasks.
    ALTER TABLE products FORCE ROW LEVEL SECURITY;

    If you try to SELECT * FROM products; now (as a non-superuser), you will get zero rows back. RLS is working, blocking everything by default. We need to create policies to grant access.

    Step 2: Crafting Dynamic RLS Policies with Session Variables

    RLS policies are expressions that return a boolean. If the expression is true for a given row, that row is visible or can be operated upon. We'll use the current_setting() function to read the session variables we'll set later from our application.

    sql
    -- A helper function to get a claim, returning NULL if not found.
    -- The second parameter to current_setting('...', true) makes it not throw an error if the setting is missing.
    CREATE OR REPLACE FUNCTION get_jwt_claim(claim_name TEXT) RETURNS TEXT AS $$
    BEGIN
        RETURN current_setting('request.jwt.claims.' || claim_name, true);
    EXCEPTION
        WHEN UNDEFINED_OBJECT THEN
            RETURN NULL;
    END;
    $$ LANGUAGE plpgsql STABLE;
    
    -- Policy for SELECT operations
    -- This policy allows a user to see products if their org_id claim matches the product's organization_id
    CREATE POLICY select_products_own_org
    ON products
    FOR SELECT
    USING (organization_id::text = get_jwt_claim('org_id'));
    
    -- Policy for INSERT operations
    -- The WITH CHECK clause is used for INSERT and UPDATE.
    -- It ensures that any new row being created (or an existing row being moved)
    -- adheres to the policy.
    CREATE POLICY insert_products_own_org
    ON products
    FOR INSERT
    WITH CHECK (organization_id::text = get_jwt_claim('org_id'));
    
    -- Policy for UPDATE operations
    -- 'USING' applies to which rows can be seen and therefore updated.
    -- 'WITH CHECK' ensures you can't update a row to move it to an organization you don't belong to.
    CREATE POLICY update_products_own_org
    ON products
    FOR UPDATE
    USING (organization_id::text = get_jwt_claim('org_id'))
    WITH CHECK (organization_id::text = get_jwt_claim('org_id'));
    
    -- Policy for DELETE operations
    -- Only 'USING' is needed here, as you can only delete rows you can see.
    CREATE POLICY delete_products_own_org
    ON products
    FOR DELETE
    USING (organization_id::text = get_jwt_claim('org_id'));

    A key distinction here is USING vs. WITH CHECK.

    * USING (expression): Applies to commands that read data (SELECT) and determines which existing rows are visible to the command (UPDATE, DELETE).

    WITH CHECK (expression): Applies to commands that write data (INSERT, UPDATE). It ensures that the new* data being written satisfies the expression.

    For UPDATE, you often need both. USING ensures you can only target rows in your own tenant, and WITH CHECK prevents you from changing the organization_id to a tenant you don't have access to.

    Step 3: The Microservice Middleware (Node.js & `node-postgres`)

    Now for the application-side implementation. This middleware is the bridge that injects the JWT context into the database session. We'll use Node.js with Express and the pg (node-postgres) library.

    javascript
    // file: jwt-rls-middleware.js
    const jwt = require('jsonwebtoken');
    const { Pool } = require('pg');
    
    const JWT_SECRET = 'your-super-secret-key'; // Use environment variables in production!
    
    // Assume you have a configured pg Pool instance
    const pool = new Pool({
        connectionString: 'postgresql://user:password@host:port/database'
    });
    
    // A mock function to generate tokens for testing
    function generateTestToken(userId, orgId, role) {
        const payload = {
            sub: userId,
            org_id: orgId,
            role: role,
        };
        return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
    }
    
    // The core middleware
    async function rlsMiddleware(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.substring(7);
    
        let decodedToken;
        try {
            decodedToken = jwt.verify(token, JWT_SECRET);
        } catch (err) {
            return res.status(401).send('Unauthorized: Invalid token.');
        }
    
        // Get a client from the pool. This client will be used for the entire request.
        const client = await pool.connect();
        req.dbClient = client; // Attach client to the request object
    
        try {
            // CRITICAL: Start a transaction.
            await client.query('BEGIN');
    
            // Securely set the session variables using parameterized queries to prevent SQL injection.
            // We use SET LOCAL so the setting only lasts for the current transaction.
            await client.query(`SET LOCAL "request.jwt.claims.sub" = $1`, [decodedToken.sub]);
            await client.query(`SET LOCAL "request.jwt.claims.org_id" = $1`, [decodedToken.org_id]);
            await client.query(`SET LOCAL "request.jwt.claims.role" = $1`, [decodedToken.role]);
    
            // Proceed to the actual route handler
            next();
    
        } catch (err) {
            // If setting claims fails, rollback and release the client
            await client.query('ROLLBACK');
            client.release();
            console.error('Failed to set RLS context:', err);
            res.status(500).send('Internal Server Error');
        }
    }
    
    // Middleware to commit/rollback and release the client after the request is handled
    async function transactionFinalizer(req, res, next) {
        if (!req.dbClient) return next();
    
        // The response has been sent, so we can clean up.
        // We listen for the 'finish' event on the response object.
        res.on('finish', async () => {
            try {
                // Check if the transaction is still active before trying to commit/rollback
                // This handles cases where the route handler might have already committed.
                const { rows } = await req.dbClient.query("SELECT txid_current_if_assigned() IS NOT NULL AS in_transaction");
                if (rows[0].in_transaction) {
                    // If headers have been sent and status is 2xx, commit.
                    // Otherwise, rollback.
                    if (res.headersSent && res.statusCode >= 200 && res.statusCode < 300) {
                        await req.dbClient.query('COMMIT');
                    } else {
                        await req.dbClient.query('ROLLBACK');
                    }
                }
            } catch (err) {
                console.error('Error during transaction finalization:', err);
                // Even if finalization fails, we MUST release the client
            } finally {
                req.dbClient.release();
            }
        });
        next();
    }
    
    module.exports = { rlsMiddleware, transactionFinalizer, generateTestToken };

    Why SET LOCAL and Transactions are Non-Negotiable:

    When using a connection pool, the same database connection will be reused by different requests from different users. If you were to use SET instead of SET LOCAL, the setting would persist on that connection after the request is finished. The next user to get that connection from the pool would inherit the previous user's identity, a massive security vulnerability.

    By wrapping the entire request in a transaction and using SET LOCAL, we guarantee that our session variables are automatically discarded at COMMIT or ROLLBACK. This makes the pattern safe for use with connection pools.

    Here's how you'd use it in an Express app:

    javascript
    // file: server.js
    const express = require('express');
    const { rlsMiddleware, transactionFinalizer, generateTestToken } = require('./jwt-rls-middleware');
    
    const app = express();
    
    // Apply middleware to all protected routes
    app.use('/api', rlsMiddleware, transactionFinalizer);
    
    app.get('/api/products', async (req, res) => {
        try {
            // The business logic is now beautifully simple.
            // No need to pass org_id or user_id around.
            const { rows } = await req.dbClient.query('SELECT * FROM products');
            res.status(200).json(rows);
        } catch (err) {
            // The finalizer middleware will handle rollback
            console.error(err);
            res.status(500).send('Error fetching products');
        }
    });
    
    // Example of how to generate tokens for testing with cURL
    app.get('/token', (req, res) => {
        const tokenA = generateTestToken('user-1', 'a4e3c2b1-3b6a-4b0d-8215-25d2f3a8b4f2', 'member');
        const tokenB = generateTestToken('user-2', 'b5f4d3c2-4c7b-5c1e-9326-36e3g4b9c5g3', 'member');
        res.json({ for_org_a: tokenA, for_org_b: tokenB });
    });
    
    app.listen(3000, () => console.log('Server running on port 3000'));

    Now, if you run this server and make a request with a token for Org A, you will only see "Widget A". With a token for Org B, you will only see "Gadget B". The application code in the route handler is completely ignorant of tenancy, as it should be.

    Advanced Scenarios and Edge Cases

    Role-Based Access Control (RBAC)

    What if an organization has admins who can see all products and members who can only see their own? We can add a role claim to the JWT and create a more complex policy structure. PostgreSQL policies of the same type (PERMISSIVE, the default) are combined with OR.

    sql
    -- Add a policy to allow users with the 'admin' role to see all products in their org
    -- For a support tool, this could be a global admin role that bypasses the org check entirely
    CREATE POLICY select_products_admin_all_orgs
    ON products
    FOR SELECT
    USING (get_jwt_claim('role') = 'super_admin');

    Now, a user's access is determined by (is_super_admin) OR (org_id_matches). If a user with the super_admin role makes a request, the first policy will evaluate to true, and they will see all products. For a regular member, the first policy is false, but the second (select_products_own_org) will be true for their organization's products.

    Handling Background Workers and System Tasks

    A background worker processing a queue or a cron job running nightly reports has no user-initiated JWT. How does it operate on the data?

    Option 1 (The Dangerous Way): Create a dedicated PostgreSQL user for the worker and grant it the BYPASSRLS attribute. This is simple but effectively punches a hole in your security model. A compromise of the worker's credentials gives full access.

    Option 2 (The Secure Way): Treat the system as its own identity.

    • Create a service-to-service authentication mechanism (e.g., OAuth Client Credentials flow) where the worker can obtain a JWT.
  • This system-level JWT should contain specific claims, like role: 'system_worker'.
    • Create RLS policies that recognize this role.
    sql
    -- Policy allowing a system worker to access any product
    CREATE POLICY system_worker_access
    ON products
    FOR ALL -- Applies to SELECT, INSERT, UPDATE, DELETE
    USING (get_jwt_claim('role') = 'system_worker')
    WITH CHECK (get_jwt_claim('role') = 'system_worker');

    This approach maintains the integrity of the RLS-first security model. All data access, whether from a user or a system, is governed by policies, and you have a clear audit trail based on the JWTs used.

    Performance Considerations

    RLS is not free. Every query against an RLS-enabled table incurs the overhead of executing the policy functions.

    Let's analyze the impact. Consider our query SELECT * FROM products;.

    Without RLS, the plan might be a simple Seq Scan on products.

    With RLS, EXPLAIN ANALYZE SELECT * FROM products; will reveal a different plan:

    text
    Seq Scan on products  (cost=0.00..35.50 rows=1 width=60) (actual time=0.032..0.033 rows=1 loops=1)
      Filter: (organization_id = ((current_setting('request.jwt.claims.org_id'::text, true))::uuid))
      Rows Removed by Filter: 1
    Planning Time: 0.115 ms
    Execution Time: 0.053 ms

    The key is the Filter line. PostgreSQL has appended our RLS policy as a WHERE clause. The performance implications are therefore identical to having that WHERE clause in your application code.

    Optimization Strategies:

  • Index Tenancy Columns: The column used for tenancy checks (organization_id in our case) must be indexed. The RLS filter is fully capable of using this index.
  • Keep Policies Simple: Avoid complex subqueries, joins, or volatile functions inside your policy's USING clause. current_setting() is very fast, but a complex query inside the policy will run for every single row being considered, leading to disastrous performance.
  • Use STABLE or IMMUTABLE Functions: If you create helper functions for your policies (like our get_jwt_claim), declare them with the appropriate volatility. STABLE indicates the function's result won't change within a single scan, allowing the planner to optimize it.
  • The performance cost is typically negligible compared to the massive security and maintainability benefits. The cost of one developer forgetting one WHERE clause in application code is far higher.

    Conclusion: A Robust Foundation for Secure SaaS

    By leveraging PostgreSQL RLS with JWT claim propagation, we shift the responsibility of tenancy enforcement from the application developer to the database schema. This creates a secure-by-default posture where it's impossible for a microservice's business logic to accidentally leak data across tenants.

    The pattern—JWT -> Middleware -> SET LOCAL in Transaction -> RLS Policy—provides a centralized, auditable, and unbypassable security boundary. While it requires careful setup of middleware and a solid understanding of database transactions and connection pooling, the payoff is a significantly more robust and maintainable multi-tenant architecture. The application code becomes simpler, concerned only with business logic, while the database itself stands as the ultimate guardian of data isolation.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles