Postgres RLS with JWTs for Granular API Authorization

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

In a mature microservices architecture, enforcing authorization is a recurring, complex challenge. The common pattern involves placing authorization logic directly within each service. A request arrives with a JWT, the service validates it, extracts claims like user_id and organization_id, and then meticulously adds WHERE clauses to every single database query.

javascript
// The pattern we want to eliminate
async function getProjects(userId, orgId) {
  // Every developer must remember to add these clauses
  const query = 'SELECT * FROM projects WHERE user_id = $1 AND organization_id = $2';
  const { rows } = await pool.query(query, [userId, orgId]);
  return rows;
}

This approach is fragile. A single forgotten WHERE clause in one of the dozens of data-access functions across multiple services creates a critical security vulnerability. It violates the DRY principle, leading to boilerplate code, and makes auditing security rules a distributed nightmare. As permission models grow in complexity—introducing roles, teams, and object-specific permissions—this application-layer approach becomes untenable.

This article presents a more robust, centralized, and performant alternative: delegating authorization enforcement to the database itself using PostgreSQL's Row-Level Security (RLS), driven by claims from a stateless JWT.

We will construct a complete, production-ready pattern where the application's only responsibility is to securely pass user context to the database. The database then becomes the ultimate, non-bypassable authority on who can see and modify what data. This is not a theoretical exercise; it's a deep dive into the implementation details, performance tuning, and edge cases you'll face in a real-world system.

The Architectural Flow

Before diving into code, let's visualize the end-to-end request flow in this architecture:

  • Client Request: The client sends a request to an API endpoint with a valid JWT in the Authorization header.
  • API Gateway/Middleware: A gateway or global middleware validates the JWT's signature and expiration. If valid, it passes the request and the decoded JWT payload to the appropriate microservice.
  • Microservice Context Propagation: The microservice receives the request. Instead of using JWT claims to build queries, it acquires a database connection and sets the claims as session-local configuration variables within a transaction.
  • Database Enforcement (RLS): The microservice executes a simple, business-logic-only query (e.g., SELECT * FROM projects). PostgreSQL intercepts this query and transparently appends WHERE conditions based on the RLS policies defined on the projects table and the session variables set in the previous step.
  • Secure Data Response: The database returns only the rows the user is authorized to see. The application code remains blissfully unaware of the underlying security filtering.
  • This pattern centralizes authorization logic right next to the data it protects, making it impossible for a developer to accidentally forget a security check.

    Section 1: Schema and JWT Structure

    Let's establish a realistic multi-tenant schema for a project management application. This schema will be the foundation for our RLS policies.

    sql
    -- A simple multi-tenant schema
    CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
    
    CREATE TABLE organizations (
        id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
        name TEXT NOT NULL
    );
    
    CREATE TYPE user_role AS ENUM ('member', 'admin');
    
    CREATE TABLE users (
        id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
        organization_id UUID NOT NULL REFERENCES organizations(id),
        email TEXT NOT NULL UNIQUE,
        role user_role NOT NULL DEFAULT 'member'
    );
    
    CREATE TABLE projects (
        id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
        organization_id UUID NOT NULL REFERENCES organizations(id),
        name TEXT NOT NULL
    );
    
    -- A join table for many-to-many relationship between users and projects
    CREATE TABLE project_members (
        project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
        user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        PRIMARY KEY (project_id, user_id)
    );
    
    -- Create indexes that will be critical for RLS performance
    CREATE INDEX ON users (organization_id);
    CREATE INDEX ON projects (organization_id);
    CREATE INDEX ON project_members (user_id);

    Our JWT payload will contain the essential claims needed to drive our authorization logic. A decoded payload might look like this:

    json
    {
      "sub": "a1b2c3d4-e5f6-7890-1234-56789abcdef0", // User ID
      "org_id": "f0e9d8c7-b6a5-4321-fedc-ba9876543210", // Organization ID
      "role": "admin", // User's role within the organization
      "iat": 1678886400,
      "exp": 1678890000
    }

    Section 2: The Core Mechanism: Passing JWT Context to PostgreSQL

    The linchpin of this entire pattern is securely passing the JWT claims into the database session so RLS policies can access them. We cannot simply interpolate these values into queries—that would defeat the purpose and reintroduce the risk of developer error.

    Instead, we use PostgreSQL's session-local configuration parameters. We can set custom, namespaced variables that exist only for the duration of the current transaction.

    Here's how to implement this in a Node.js application using the pg library. The same principle applies to any language and database driver.

    First, we establish a dedicated, non-superuser role that our application will use to connect to the database.

    sql
    -- Create a low-privilege role for the application
    CREATE ROLE app_user LOGIN PASSWORD 'your_secure_password';
    
    -- Grant basic permissions. We don't grant direct table access here.
    -- Access will be governed by RLS.
    GRANT CONNECT ON DATABASE your_database TO app_user;
    GRANT USAGE ON SCHEMA public TO app_user;
    
    -- The role will need permissions to SELECT, INSERT, etc. on tables.
    -- RLS will then filter what this role is allowed to do.
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;

    Now, let's create a higher-order function or middleware in our application that wraps database operations. This wrapper will be responsible for setting the JWT context.

    javascript
    // Node.js with 'pg' library
    const { Pool } = require('pg');
    
    const pool = new Pool({
      user: 'app_user', // Connect as our low-privilege user
      // ... other connection details
    });
    
    // A higher-order function to wrap database logic with JWT context
    async function runInTransactionWithUserContext(jwtPayload, callback) {
      const client = await pool.connect();
    
      try {
        await client.query('BEGIN');
    
        // Set claims as session-local variables. The namespace 'request.jwt.claims' is arbitrary but good practice.
        // IMPORTANT: Use SET LOCAL to ensure the setting only lasts for the current transaction.
        // This is critical for safety with connection pooling.
        const setSub = client.query(`SET LOCAL request.jwt.claims.sub = '${jwtPayload.sub}';`);
        const setOrg = client.query(`SET LOCAL request.jwt.claims.org_id = '${jwtPayload.org_id}';`);
        const setRole = client.query(`SET LOCAL request.jwt.claims.role = '${jwtPayload.role}';`);
    
        // Wait for all settings to be applied
        await Promise.all([setSub, setOrg, setRole]);
    
        // Now, execute the actual business logic passed in the callback
        const result = await callback(client);
    
        await client.query('COMMIT');
        return result;
      } catch (e) {
        await client.query('ROLLBACK');
        throw e; // Re-throw the error after rolling back
      } finally {
        client.release(); // Release the connection back to the pool
      }
    }
    
    // Example usage in an Express.js route handler
    app.get('/projects', async (req, res) => {
      // Assume req.user is populated by JWT validation middleware
      const jwtPayload = req.user;
    
      try {
        const projects = await runInTransactionWithUserContext(jwtPayload, async (client) => {
          // The query is now clean of authorization logic
          const { rows } = await client.query('SELECT id, name FROM projects');
          return rows;
        });
        res.json(projects);
      } catch (error) {
        console.error('Failed to fetch projects:', error);
        res.status(500).send('Internal Server Error');
      }
    });

    Critical Implementation Detail: The use of SET LOCAL is non-negotiable in an environment with connection pooling. SET (without LOCAL) would set the variable for the entire session. If that connection is later returned to the pool and reused by a different user's request, it would inherit the previous user's session variables, leading to a catastrophic security breach. SET LOCAL ensures the setting is automatically discarded at COMMIT or ROLLBACK.

    Section 3: Crafting Powerful RLS Policies

    With the context-passing mechanism in place, we can now define the RLS policies on our tables. Policies are essentially rules that PostgreSQL appends to queries.

    Let's start with a basic policy for the projects table.

    Policy 1: Basic Tenant Isolation

    A user should only be able to see projects that belong to their organization.

    sql
    -- First, enable RLS on the table.
    ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
    
    -- Create a policy for SELECT operations
    CREATE POLICY select_projects_in_organization ON projects
    FOR SELECT
    USING ( 
      organization_id = current_setting('request.jwt.claims.org_id')::uuid
    );

    Let's break this down:

    * ALTER TABLE ... ENABLE ROW LEVEL SECURITY;: This command activates RLS for the projects table. Without this, no policies will be enforced.

    * CREATE POLICY ...: Defines a new policy named select_projects_in_organization.

    * FOR SELECT: This policy applies only to SELECT queries.

    * USING (...): This is the core of the policy. The expression inside the parentheses is effectively added as a WHERE clause to any SELECT query on projects. The current_setting() function safely retrieves the session variable we set earlier. We cast it to uuid to match the column type.

    Now, when our application runs SELECT * FROM projects, PostgreSQL transforms it behind the scenes into:

    sql
    SELECT * FROM projects WHERE organization_id = 'f0e9d8c7-b6a5-4321-fedc-ba9876543210'; -- (value from the session variable)

    Policy 2: Role-Based Access and Complex Conditions

    Let's create a more nuanced rule. An admin can see all projects in their organization, but a member can only see projects they are explicitly assigned to via the project_members table.

    To do this, we can combine multiple policies or create a single, more complex one. A single policy is often cleaner.

    sql
    -- Drop the old policy to replace it
    DROP POLICY select_projects_in_organization ON projects;
    
    -- Create a new, more advanced policy
    CREATE POLICY select_projects_based_on_role ON projects
    FOR SELECT
    USING (
      -- First, every user must belong to the correct organization
      organization_id = current_setting('request.jwt.claims.org_id')::uuid
      AND (
        -- Condition 1: The user is an admin
        current_setting('request.jwt.claims.role') = 'admin'
        OR
        -- Condition 2: The user is a member of the project
        EXISTS (
          SELECT 1 FROM project_members pm
          WHERE pm.project_id = projects.id
          AND pm.user_id = current_setting('request.jwt.claims.sub')::uuid
        )
      )
    );

    This policy demonstrates the power of RLS. We can use subqueries, check different session variables, and create complex boolean logic that is impossible for the application to bypass.

    Policy 3: Policies for `INSERT`, `UPDATE`, and `DELETE`

    RLS isn't just for reading data. You can control modifications using the same mechanism, often with an additional WITH CHECK clause to validate incoming data.

    Let's create policies for INSERT and UPDATE on projects.

    sql
    -- Policy for INSERT: A user can only create a project in their own organization.
    CREATE POLICY insert_projects_in_organization ON projects
    FOR INSERT
    WITH CHECK (
      organization_id = current_setting('request.jwt.claims.org_id')::uuid
    );
    
    -- Policy for UPDATE: An admin can update any project name in their org.
    -- A member can only update projects they are part of.
    CREATE POLICY update_projects_based_on_role ON projects
    FOR UPDATE
    USING (
      -- The USING clause here defines which rows are even visible for updating
      -- It's often the same as the SELECT policy
      organization_id = current_setting('request.jwt.claims.org_id')::uuid
    )
    WITH CHECK (
      -- The WITH CHECK clause validates the *new* data being written
      -- Ensure the org_id isn't being changed to a different organization
      organization_id = current_setting('request.jwt.claims.org_id')::uuid
      AND (
        -- Admins can update any project (that was visible via USING)
        current_setting('request.jwt.claims.role') = 'admin'
        OR
        -- Members must be part of the project to update it
        EXISTS (
          SELECT 1 FROM project_members pm
          WHERE pm.project_id = projects.id
          AND pm.user_id = current_setting('request.jwt.claims.sub')::uuid
        )
      )
    );

    The WITH CHECK clause is crucial for write operations. It prevents a user from, for example, creating a project and assigning it to a different organization, or updating a project to move it to an organization they don't belong to.

    Section 4: Performance Considerations and Optimization

    RLS is incredibly powerful, but it's not free. Every query against an RLS-enabled table incurs the overhead of executing the policy checks. Poorly written policies or missing indexes can devastate your application's performance.

    The `EXPLAIN ANALYZE` Deep Dive

    Always, always analyze your query plans when implementing RLS. Let's look at the plan for our SELECT query on projects.

    sql
    -- Simulate the application's environment for analysis
    BEGIN;
    SET LOCAL request.jwt.claims.sub = 'a1b2c3d4-e5f6-7890-1234-56789abcdef0';
    SET LOCAL request.jwt.claims.org_id = 'f0e9d8c7-b6a5-4321-fedc-ba9876543210';
    SET LOCAL request.jwt.claims.role = 'member';
    
    EXPLAIN ANALYZE SELECT * FROM projects;
    
    ROLLBACK;

    If the organization_id column on projects is indexed, you should see a plan that uses an Index Scan.

    text
    ->  Bitmap Heap Scan on projects  (cost=12.54..36.96 rows=10 width=56) (actual time=0.046..0.048 rows=5 loops=1)
          Recheck Cond: (organization_id = 'f0e9d8c7-b6a5-4321-fedc-ba9876543210'::uuid)
          Filter: ((current_setting('request.jwt.claims.role'::text) = 'admin'::text) OR (alternatives: ...))

    If you see a Sequential Scan (Seq Scan) on a large table, it's a red flag. This means PostgreSQL is reading the entire table and applying the policy filter to every single row, which will be disastrously slow. The most common cause is a missing index on the column(s) used in your USING clause (e.g., organization_id).

    Policy Complexity and Function Usage

    The EXISTS subquery in our select_projects_based_on_role policy adds overhead. For every row in projects being considered, PostgreSQL must perform a lookup on project_members. This is where having an index on project_members (project_id, user_id) is absolutely critical for performance.

    For very complex or reusable logic, you can encapsulate it in a function.

    sql
    CREATE FUNCTION is_project_member(p_project_id UUID, p_user_id UUID) RETURNS BOOLEAN AS $$
      SELECT EXISTS (
        SELECT 1 FROM project_members
        WHERE project_id = p_project_id AND user_id = p_user_id
      );
    $$ LANGUAGE sql STABLE;

    Then, your policy becomes cleaner:

    sql
    -- ...
    OR is_project_member(projects.id, current_setting('request.jwt.claims.sub')::uuid)
    -- ...

    Performance Tip: Declaring the function as STABLE tells the query planner that it doesn't modify the database and returns the same result for the same arguments within a single scan. This allows for better optimization. If the function depended only on its arguments and not on database lookups, you could mark it IMMUTABLE for even more aggressive optimization.

    Section 5: Advanced Patterns and Edge Cases

    Real-world systems have complexities that go beyond the basic patterns.

    Edge Case 1: The Superuser / Internal Service Bypass

    How do background workers, cron jobs, or internal administrative services access all data without being subject to RLS? You cannot simply use the PostgreSQL superuser, as that's a major security risk.

    The correct pattern is to create a specific role that bypasses RLS.

    sql
    -- Create a role for internal services
    CREATE ROLE internal_service LOGIN PASSWORD 'another_secure_password';
    
    -- This attribute is the key
    ALTER ROLE internal_service BYPASSRLS;
    
    -- Grant necessary permissions
    GRANT CONNECT ON DATABASE your_database TO internal_service;
    GRANT USAGE ON SCHEMA public TO internal_service;
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO internal_service;

    Your internal services would then use a separate connection pool configured with the internal_service user. Any query from this pool will completely ignore all RLS policies.

    Edge Case 2: Testing RLS Policies

    Testing RLS is notoriously difficult because the logic lives outside your application code. The best approach is to write integration tests that run against a real database.

    Here's a testing strategy using Jest and node-postgres:

    javascript
    // In your test file (e.g., projects.test.js)
    describe('Project RLS Policies', () => {
      let adminClient, memberClient, otherOrgClient;
    
      // A helper to create a client with a specific user context
      const createClientWithContext = async (payload) => {
        const client = await pool.connect();
        await client.query('BEGIN');
        await client.query(`SET LOCAL request.jwt.claims.sub = '${payload.sub}';`);
        await client.query(`SET LOCAL request.jwt.claims.org_id = '${payload.org_id}';`);
        await client.query(`SET LOCAL request.jwt.claims.role = '${payload.role}';`);
        return client;
      };
    
      beforeAll(async () => {
        // Setup test data: org1, org2, adminUser, memberUser, project1 (in org1), project2 (in org2)
      });
    
      test('Admin should see all projects in their own organization', async () => {
        const client = await createClientWithContext({ sub: 'admin_user_id', org_id: 'org1_id', role: 'admin' });
        const { rows } = await client.query('SELECT id FROM projects');
        expect(rows).toHaveLength(1);
        expect(rows[0].id).toBe('project1_id');
        await client.query('ROLLBACK');
        client.release();
      });
    
      test('Member should not see projects in another organization', async () => {
        const client = await createClientWithContext({ sub: 'member_user_id', org_id: 'org2_id', role: 'member' });
        const { rows } = await client.query('SELECT id FROM projects WHERE id = \'project1_id\'');
        expect(rows).toHaveLength(0);
        await client.query('ROLLBACK');
        client.release();
      });
    
      // ... more tests for members, inserts, updates, etc.
    });

    This pattern allows you to write precise tests for your security model. Each test runs in its own transaction, impersonating a different user, and is rolled back at the end, ensuring tests are isolated and don't pollute the database.

    Edge Case 3: Handling Anonymous Users or Missing Claims

    What happens if a request has no JWT, or a JWT is missing a claim? current_setting('...', true) can be used, where the second argument true means it will return NULL if the setting is not found, instead of throwing an error.

    Your policies should be robust enough to handle NULL values.

    sql
    -- A policy that denies access if the org_id is missing
    USING (
      organization_id = COALESCE(current_setting('request.jwt.claims.org_id', true), '00000000-0000-0000-0000-000000000000')::uuid
    )

    Here, COALESCE will substitute a non-existent UUID if the setting is null, ensuring the equality check fails and no rows are returned.

    Conclusion: RLS as a Foundation for Secure Systems

    Shifting authorization logic from the application layer to the database via PostgreSQL RLS is a significant architectural decision. It's not a silver bullet, and it introduces a new class of potential performance issues and testing complexities.

    Pros:

    * Unbypassable Security: The rules are enforced by the database, regardless of which microservice or client is accessing the data.

    * Centralized Logic: Authorization rules live in one place—the database schema—making them easier to audit and manage.

    * Simplified Application Code: Application developers can focus on business logic, writing simple, naive queries without worrying about security clauses.

    Cons:

    * Increased Database Complexity: Authorization logic is now part of your database schema and requires database expertise to write and debug.

    * Performance Pitfalls: A poorly written policy or a missing index can have a severe impact on performance.

    * Tooling and Debugging: Debugging RLS can be more difficult than stepping through application code. Heavy reliance on EXPLAIN ANALYZE is a must.

    For multi-tenant SaaS applications and systems with well-defined, data-centric permission models, the security and maintainability benefits of using RLS with JWTs are profound. By centralizing enforcement at the data layer, you build a foundation of security that is robust, scalable, and far less prone to human error than its application-layer counterpart.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles