Postgres RLS with JWTs in a Zero-Trust Microservice Architecture

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

In a distributed microservices ecosystem, authorization is a pervasive cross-cutting concern. The common pattern is regrettably simple and dangerously flawed: each microservice is responsible for implementing its own authorization logic. An incoming request, authenticated via an API gateway and carrying a JWT, is parsed by the service, which then manually injects tenant_id or user_id clauses into its database queries.

Consider this typical data access layer code in a Node.js invoices-service:

javascript
// services/invoices/repository.js

class InvoiceRepository {
  constructor(dbPool) {
    this.pool = dbPool;
  }

  async findById(invoiceId, tenantId) {
    // Manually adding the tenant_id check is required for every query
    const query = 'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2';
    const { rows } = await this.pool.query(query, [invoiceId, tenantId]);
    return rows[0];
  }

  async findForUser(userId, tenantId, page, limit) {
    // Another manual check, easy to forget
    const query = 'SELECT * FROM invoices WHERE created_by = $1 AND tenant_id = $2 LIMIT $3 OFFSET $4';
    const offset = (page - 1) * limit;
    const { rows } = await this.pool.query(query, [userId, tenantId, limit, offset]);
    return rows;
  }
  
  // ... dozens more methods, each needing manual tenantId/userId checks
}

This approach is an architectural liability. It violates the DRY principle, leading to massive code duplication. More critically, it creates a vast surface area for security vulnerabilities. A single developer forgetting a single WHERE tenant_id = ? clause on a new or refactored query introduces a catastrophic data leak. The authorization logic is decentralized, inconsistent, and fragile. It relies on developer discipline, which is not a scalable security strategy.

The Architectural Shift: Zero-Trust and Database-Level Enforcement

We can architect a more robust solution by adopting a zero-trust philosophy and pushing authorization enforcement down to the one layer that truly owns the data: the database. In this model, the application code becomes blissfully ignorant of tenancy and ownership. It simply asks for "invoice 123", and the database itself is responsible for determining if the current user is allowed to see it.

PostgreSQL's Row-Level Security (RLS) is the mechanism that makes this possible. RLS allows you to define policies on a table that act as an implicit, mandatory WHERE clause for every query executed against it. The challenge, however, is making these policies aware of the application-level user context (who is making the request?).

The bridge between our stateless application and the stateful database session is the JWT. We can extract claims from a validated JWT and pass them securely into the PostgreSQL session for each transaction. These session variables can then be referenced directly within our RLS policies.

Here's the core data flow:

  • API Gateway: Validates the JWT signature. Passes the encoded JWT to the upstream microservice.
  • Microservice Middleware: Decodes the JWT. For every incoming request, it acquires a database connection.
  • Transaction Scoping: Before executing any business logic, it begins a transaction and sets transaction-local session variables using the JWT claims (e.g., SET LOCAL jwt.claims.tenant_id = '...').
  • Data Access: The application's repository code runs simple, authorization-agnostic queries (e.g., SELECT * FROM invoices WHERE id = $1).
  • PostgreSQL RLS Enforcement: Postgres sees the query, consults the RLS policy on the invoices table, and internally rewrites the query to include the policy's conditions (e.g., ... AND tenant_id = current_setting('jwt.claims.tenant_id')::uuid).
  • Transaction Cleanup: Upon COMMIT or ROLLBACK, the transaction-local settings are automatically discarded, ensuring the connection is clean when returned to the pool.
  • This architecture centralizes authorization logic in one place (the database schema), makes it impossible to forget, and dramatically simplifies application code.

    Production Implementation: A Step-by-Step Guide

    Let's build a production-grade implementation using Node.js, Express, and the pg library.

    Step 1: Define the Schema and RLS Policies

    First, we define our tables and enable RLS. We'll use a simple multi-tenant schema.

    sql
    -- Enable the pgcrypto extension for UUIDs
    CREATE EXTENSION IF NOT EXISTS "pgcrypto";
    
    -- Create our 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(),
        email TEXT NOT NULL UNIQUE,
        tenant_id UUID NOT NULL REFERENCES tenants(id)
    );
    
    CREATE TABLE invoices (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        tenant_id UUID NOT NULL REFERENCES tenants(id),
        created_by_user_id UUID NOT NULL REFERENCES users(id),
        amount_cents INT NOT NULL,
        status TEXT NOT NULL DEFAULT 'draft',
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    
    -- IMPORTANT: Enable Row-Level Security on the tables that need protection
    ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
    -- We can also force it, which protects against the owner bypassing it.
    ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
    
    -- Create a role for our application to use. It should NOT be a superuser.
    CREATE ROLE application_user LOGIN PASSWORD 'your_secure_password';
    GRANT CONNECT ON DATABASE your_database TO application_user;
    GRANT USAGE ON SCHEMA public TO application_user;
    GRANT SELECT, INSERT, UPDATE, DELETE ON invoices, users, tenants TO application_user;

    Now, we create the policies. We'll use custom configuration parameters within a jwt.claims namespace to hold our JWT data. This is a clean way to avoid collisions with standard Postgres settings.

    sql
    -- This policy enforces strict tenant isolation.
    -- It checks a session variable that we will set from our application.
    CREATE POLICY tenant_isolation_policy ON invoices
    AS PERMISSIVE FOR ALL
    TO application_user
    USING (tenant_id = current_setting('jwt.claims.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('jwt.claims.tenant_id')::uuid);
  • AS PERMISSIVE: Means policies are combined with OR. RESTRICTIVE policies are combined with AND and can deny access even if a PERMISSIVE policy grants it. For simple tenancy, PERMISSIVE is fine.
  • FOR ALL: Applies to SELECT, INSERT, UPDATE, and DELETE.
  • TO application_user: The policy only applies when this role is active.
  • USING (...): This clause is for read access (SELECT). The query will only return rows that satisfy this condition.
  • WITH CHECK (...): This clause is for write access (INSERT, UPDATE). It ensures any new or modified row must satisfy this condition. This prevents a user from one tenant from creating an invoice for another tenant.
  • Step 2: The JWT-Aware Database Middleware

    This is the most critical piece of application code. It must reliably set and clear the session variables for every transaction.

    We'll create a higher-order function that wraps our Express route handlers, providing them with a database client that has the authorization context already configured.

    javascript
    // middleware/db-context.js
    const { Pool } = require('pg');
    
    const pool = new Pool({ /* your connection config */ });
    
    // This function takes JWT claims and sets them as transaction-local variables
    async function setAuthContext(client, claims) {
      // Using 'LOCAL' is CRITICAL. It scopes the setting to the current transaction.
      // This prevents settings from leaking between requests when using a connection pool.
      await client.query(`SET LOCAL jwt.claims.tenant_id = '${claims.tenant_id}';`);
      await client.query(`SET LOCAL jwt.claims.user_id = '${claims.sub}';`);
      await client.query(`SET LOCAL jwt.claims.role = '${claims.role}';`);
    }
    
    // The middleware factory
    function withDB(handler) {
      return async (req, res, next) => {
        const client = await pool.connect();
        try {
          // req.user is populated by a preceding authentication middleware (e.g., passport-jwt)
          if (!req.user || !req.user.tenant_id) {
            return res.status(401).send('Unauthorized: Missing JWT claims.');
          }
    
          await client.query('BEGIN');
          await setAuthContext(client, req.user); // req.user holds the decoded JWT payload
          
          // Pass the configured client to the actual request handler
          await handler(req, res, client);
    
          await client.query('COMMIT');
        } catch (error) {
          await client.query('ROLLBACK');
          // Pass to a centralized error handler
          next(error); 
        } finally {
          client.release();
        }
      };
    }
    
    module.exports = { withDB, pool };

    The use of SET LOCAL cannot be overstated. Using SET (without LOCAL) would set the variable for the entire session. When the connection is returned to the pool, that setting would persist for the next request that checks out the same connection, leading to one user executing queries with another user's permissions.

    Step 3: Simplified Route Handler and Repository

    With the middleware in place, our application logic is dramatically cleaner. The repository no longer needs to know about tenant_id.

    javascript
    // services/invoices/repository.js (The NEW version)
    
    class InvoiceRepository {
      // The client is now passed in per-method, already configured and in a transaction.
      async findById(client, invoiceId) {
        // No tenant_id check! RLS handles it.
        const query = 'SELECT * FROM invoices WHERE id = $1';
        const { rows } = await client.query(query, [invoiceId]);
        return rows[0];
      }
    
      async create(client, { createdByUserId, amountCents }) {
         // The tenant_id for the WITH CHECK clause will be pulled from the session context
         // by the database itself. We still need to provide it as a column value.
         // We can retrieve it from the context if needed for the INSERT statement.
         const tenantIdResult = await client.query(`SELECT current_setting('jwt.claims.tenant_id')::uuid as tenant_id`);
         const tenantId = tenantIdResult.rows[0].tenant_id;
    
         const query = 'INSERT INTO invoices (tenant_id, created_by_user_id, amount_cents) VALUES ($1, $2, $3) RETURNING *';
         const { rows } = await client.query(query, [tenantId, createdByUserId, amountCents]);
         return rows[0];
      }
    }

    And the Express route handler uses our withDB middleware:

    javascript
    // routes/invoices.js
    const express = require('express');
    const { withDB } = require('../middleware/db-context');
    const InvoiceRepository = require('../services/invoices/repository');
    
    const router = express.Router();
    const repo = new InvoiceRepository();
    
    // The route handler is wrapped in our middleware
    router.get('/:id', withDB(async (req, res, client) => {
      const { id } = req.params;
      
      // The repository method is now blissfully unaware of multi-tenancy.
      const invoice = await repo.findById(client, id);
      
      if (!invoice) {
        // This could mean the invoice doesn't exist OR the user can't see it.
        // From a security perspective, we return the same response.
        return res.status(404).send('Invoice not found.');
      }
      
      res.json(invoice);
    }));
    
    module.exports = router;

    Advanced Patterns and Performance Considerations

    While elegant, this pattern introduces complexities that senior engineers must address.

    1. The `current_setting()` Performance Overhead

    Calling current_setting() on every row is not free. Postgres cannot treat it as a truly IMMUTABLE value because it can change from one transaction to the next. This can impact the query planner's ability to generate optimal plans, especially for complex joins or index usage.

    Analysis:

    Use EXPLAIN (ANALYZE, BUFFERS) to inspect the query plan. Let's compare a manual WHERE clause with an RLS-driven query.

    Query A (Manual):

    EXPLAIN ANALYZE SELECT * FROM invoices WHERE tenant_id = '...';

    Query B (RLS):

    (Inside a transaction with jwt.claims.tenant_id set)

    EXPLAIN ANALYZE SELECT * FROM invoices;

    Postgres is smart. For simple equality checks like ours, it will often generate a very similar plan, using an index scan on tenant_id. The plan for Query B will show a Filter condition like (tenant_id = (current_setting('jwt.claims.tenant_id'::text))::uuid). The overhead is typically negligible for indexed lookups (sub-millisecond).

    However, the cost can become more significant if:

    • The policy is complex, involving subqueries or functions.
    • The setting is used in joins or complex expressions.
    • The planner misestimates the cardinality of the RLS-filtered result set.

    Mitigation:

  • Keep policies simple. Prefer direct equality checks on indexed columns.
  • Use SECURITY DEFINER functions: For very complex logic, you can encapsulate it in a SECURITY DEFINER function marked STABLE or IMMUTABLE if appropriate. This can provide hints to the planner, but use with extreme caution as SECURITY DEFINER functions run with the privileges of the user who defined them.
  • Regularly ANALYZE your tables: Ensure the planner has up-to-date statistics.
  • Benchmark: There is no substitute for benchmarking your specific queries under a realistic load.
  • 2. The Super-Admin / Support User Edge Case

    How do you grant access to a support user or a system process that needs to see data across all tenants? Our current policy makes this impossible.

    We can enhance our policy to handle this by checking the user's role, which we also pass in via the JWT.

    Enhanced Policy:

    sql
    -- Drop the old policy first
    DROP POLICY tenant_isolation_policy ON invoices;
    
    -- Create a new policy that handles multiple roles
    CREATE POLICY multi_role_tenancy_policy ON invoices
    AS PERMISSIVE FOR ALL
    TO application_user
    USING (
        -- A 'system_admin' role can see everything
        current_setting('jwt.claims.role') = 'system_admin'
        -- Regular users are still restricted to their tenant
        OR tenant_id = current_setting('jwt.claims.tenant_id')::uuid
    )
    WITH CHECK (
        -- When writing, you MUST be acting within your own tenant.
        -- We don't allow an admin to write records into another tenant's space via this policy.
        tenant_id = current_setting('jwt.claims.tenant_id')::uuid
    );

    Performance Warning: The OR condition can be problematic for the query planner. A query that could have used an index scan on tenant_id might now be forced into a sequential scan because the planner can't be sure which side of the OR will be true. For a large table, this is a performance disaster.

    Better Solution: Role Switching and BYPASSRLS

    A more performant and explicit approach is to have a separate path for admin users. This can be done at the application or database level.

  • Application Level: Admin-specific endpoints could use a different database connection with a different role that has the BYPASSRLS attribute. This is clean but requires careful management of connection pools.
  • sql
        CREATE ROLE admin_user LOGIN PASSWORD '...';
        ALTER ROLE admin_user BYPASSRLS;
        -- Grant permissions as needed
  • Database Level: Use SET ROLE. An admin user can connect as application_user, but their first action can be SET ROLE admin_user;. This requires a more complex middleware but keeps the privilege escalation contained within the database session.
  • 3. Connection Pooler Hell: The PgBouncer Problem

    This architecture's biggest operational hazard is its interaction with external connection poolers like PgBouncer.

  • Session Pooling Mode: This mode is completely incompatible. It can and will serve queries from different transactions on the same connection, leading to our SET LOCAL settings leaking. Do not use.
  • Transaction Pooling Mode: This is the required mode. PgBouncer ensures that a connection is dedicated to a client for the duration of a single transaction. When COMMIT or ROLLBACK is issued, the connection is cleaned (via a DISCARD ALL or similar mechanism) and returned to the pool. This works perfectly with our SET LOCAL approach because the settings are automatically discarded at the end of the transaction anyway.
  • Statement Pooling Mode: Incompatible and dangerous.
  • If you are not using an external pooler and relying solely on an in-application pool like node-pg's, you are generally safe as long as you correctly implement the client.release() pattern shown above. The node-pg pool does not perform session-level cleanup, but our SET LOCAL pattern doesn't require it.

    4. Testing and Debugging Strategies

    Testing RLS can be tricky because the logic is hidden in the database.

    Integration Testing:

    Your integration tests must simulate the entire middleware flow. A test helper function is invaluable:

    javascript
    // test/test-helper.js
    const { pool } = require('../middleware/db-context');
    
    async function runAsUser(claims, testFunction) {
      const client = await pool.connect();
      try {
        await client.query('BEGIN');
        // Manually set the context for the test
        await client.query(`SET LOCAL jwt.claims.tenant_id = '${claims.tenant_id}';`);
        await client.query(`SET LOCAL jwt.claims.user_id = '${claims.sub}';`);
        await client.query(`SET LOCAL jwt.claims.role = '${claims.role}';`);
        
        await testFunction(client);
        
        await client.query('ROLLBACK'); // Always rollback in tests to keep DB clean
      } finally {
        client.release();
      }
    }
    
    // Usage in a test file
    it('should only return invoices for the correct tenant', async () => {
      const userFromTenantA = { sub: 'user-a', tenant_id: 'tenant-a', role: 'user' };
      
      await runAsUser(userFromTenantA, async (client) => {
        const repo = new InvoiceRepository();
        // This should find invoice_a but not invoice_b
        const invoice = await repo.findById(client, 'invoice_a_id');
        expect(invoice).toBeDefined();
    
        const missingInvoice = await repo.findById(client, 'invoice_b_id');
        expect(missingInvoice).toBeUndefined();
      });
    });

    Debugging:

    When a query returns empty results, is it because there's no data or because RLS filtered it? Use auto_explain or run EXPLAIN manually.

    sql
    -- In psql, as a superuser to see the plan
    SET SESSION AUTHORIZATION application_user;
    BEGIN;
    SET LOCAL jwt.claims.tenant_id = '...';
    EXPLAIN (ANALYZE) SELECT * FROM invoices WHERE id = '...';
    ROLLBACK;

    The output will show the Filter condition added by RLS, confirming whether it's active and what value it's using.

    Conclusion: A Powerful but Sharp Tool

    Leveraging PostgreSQL RLS with JWTs offers a paradigm shift for building secure, multi-tenant microservices. It moves authorization from fragile, scattered application code to a centralized, robust enforcement point at the database layer.

    Adopt this pattern when:

    • You have strong multi-tenancy or data ownership requirements.
    • You are building a new system on a Postgres-centric stack.
    • Your team has the discipline to manage database schema migrations for policies.
    • You want to drastically reduce boilerplate authorization code in your services.

    Reconsider this pattern if:

  • Your system requires absolute minimum query latency, and the microsecond overhead of current_setting() is unacceptable for your P99 targets.
    • You use a polyglot persistence model where Postgres is not the single source of truth for secured data.
    • Your operational environment cannot guarantee the use of a transaction-level connection pooler like PgBouncer.
    • Your team is not comfortable with advanced database features and debugging query plans.

    This is not a beginner's pattern. It requires a deep understanding of the PostgreSQL execution model, transaction isolation, and connection pooling. But for the right team and project, it provides a level of security and code elegance that is difficult to achieve with application-layer enforcement alone.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles