PostgreSQL RLS: Dynamic Policies for Complex SaaS Architectures

18 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.

Beyond `tenant_id`: The Limits of Static RLS

For any senior engineer building a multi-tenant SaaS application, PostgreSQL's Row-Level Security (RLS) is an indispensable tool. The canonical example, which isolates data based on a tenant_id, is a powerful first step:

sql
-- The basic, well-known RLS pattern
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);

In this model, the application backend is responsible for setting the app.tenant_id session variable for each connection or transaction. It's simple, effective, and prevents catastrophic data leaks between tenants. However, this pattern's elegance is also its limitation. It assumes a flat permission model where a user's access is defined by a single dimension: their tenancy.

Real-world SaaS applications are rarely this simple. Permissions are often a complex graph of roles, teams, project-specific access, and organizational hierarchies. Consider this common scenario:

* An Organization has many Users and many Projects.

* A User can be an ADMIN or MEMBER within an Organization.

* A User can be assigned to specific Projects with roles like EDITOR or VIEWER.

An ADMIN of an organization should have implicit access to all* projects within that organization.

* A MEMBER should only have access to projects they are explicitly assigned to.

Attempting to model this with static RLS policies leads to a painful crossroads: either you create an explosion of database roles (which is an operational nightmare) or you write policies with complex subqueries and JOINs. The latter approach is a ticking performance bomb.

This article details a production-hardened pattern that solves this problem by making RLS policies dynamic. We will leverage the application layer's authentication context (via JWTs) to inject a rich set of permissions into the database session, allowing us to write simple, high-performance RLS policies that can enforce arbitrarily complex business logic.

The Performance Trap: RLS Policies with Internal JOINs

Let's first demonstrate why the intuitive approach is often the wrong one. Faced with the requirements above, an engineer might write a policy for the tasks table that joins through project_memberships and organization_members to check permissions.

Let's set up our schema:

sql
-- Simplified Schema for Demonstration
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name TEXT NOT NULL
);

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    org_id UUID NOT NULL REFERENCES organizations(id),
    name TEXT NOT NULL
);

CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    project_id UUID NOT NULL REFERENCES projects(id),
    title TEXT NOT NULL,
    completed BOOLEAN DEFAULT FALSE
);

-- Junction tables for permissions
CREATE TYPE org_role AS ENUM ('ADMIN', 'MEMBER');
CREATE TABLE organization_members (
    user_id UUID NOT NULL REFERENCES users(id),
    org_id UUID NOT NULL REFERENCES organizations(id),
    role org_role NOT NULL,
    PRIMARY KEY (user_id, org_id)
);

CREATE TYPE project_role AS ENUM ('EDITOR', 'VIEWER');
CREATE TABLE project_members (
    user_id UUID NOT NULL REFERENCES users(id),
    project_id UUID NOT NULL REFERENCES projects(id),
    role project_role NOT NULL,
    PRIMARY KEY (user_id, project_id)
);

A naive RLS policy on the tasks table might look like this:

sql
-- DO NOT USE THIS IN PRODUCTION
CREATE POLICY select_tasks_naive ON tasks FOR SELECT
USING (
    EXISTS (
        SELECT 1
        FROM projects p
        JOIN organization_members om ON p.org_id = om.org_id
        WHERE p.id = tasks.project_id
          AND om.user_id = current_setting('app.user_id')::uuid
          AND om.role = 'ADMIN'
    )
    OR
    EXISTS (
        SELECT 1
        FROM project_members pm
        WHERE pm.project_id = tasks.project_id
          AND pm.user_id = current_setting('app.user_id')::uuid
    )
);

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

On the surface, this seems correct. It checks if the user is an org admin (and can thus see all tasks in all projects of that org) OR if the user is a direct member of the project the task belongs to.

The problem is that PostgreSQL's query planner will, in many cases, execute these subqueries for every single row it considers during a table scan. Let's populate some data and see the impact.

sql
-- Benchmarking the naive approach
-- (Imagine millions of tasks and thousands of users)
EXPLAIN ANALYZE
SELECT count(*) FROM tasks;

Even with a modest amount of data, the query plan will reveal a significant performance degradation. The planner is forced into nested loops or other inefficient plans because the policy introduces data dependencies that are evaluated per-row. For a query that needs to scan a large number of tasks, this becomes untenable. The cost scales with (rows in tasks) * (cost of subqueries), which is a recipe for disaster.

The Dynamic Context Pattern: JWTs as a Source of Truth

The performant solution is to invert the dependency. Instead of the database pulling permission information row-by-row, we push the user's complete permission context into the database session at the beginning of a transaction. The RLS policy then becomes a simple, fast check against this pre-loaded context.

Our source of truth will be the JSON Web Token (JWT) issued by our authentication service. When a user logs in, we can encode their relevant permissions directly into the token.

Step 1: Crafting a Rich JWT Payload

Upon successful authentication, our auth service will generate a JWT with a custom https://myapp.com/claims namespace. This claim will contain all the necessary information for our RLS policies.

json
{
  "sub": "user-uuid-c4a1...",
  "exp": 1678886400,
  "iat": 1678882800,
  "https://myapp.com/claims": {
    "org_id": "org-uuid-b21a...",
    "org_role": "MEMBER",
    "projects": [
      {"id": "project-uuid-1111...", "role": "EDITOR"},
      {"id": "project-uuid-2222...", "role": "VIEWER"}
    ]
  }
}

For an ADMIN user, the payload might be simpler, as their role implies broader access:

json
{
  "sub": "user-uuid-d5b2...",
  "exp": 1678886400,
  "iat": 1678882800,
  "https://myapp.com/claims": {
    "org_id": "org-uuid-b21a...",
    "org_role": "ADMIN"
  }
}

Security Note: The integrity of this JWT is paramount. It must be signed with a strong, secure key (e.g., RS256) and validated by the application backend on every request. The database implicitly trusts the context set by the application.

Step 2: Injecting Context into the PostgreSQL Session

Our application backend (e.g., a Node.js service using the pg library) will be the gatekeeper. Before executing any query on behalf of the user, it must set the JWT claims as a session variable. Using SET LOCAL is critical as it scopes the setting to the current transaction, ensuring it's automatically cleaned up and doesn't leak between requests handled by the same database connection from a pool.

javascript
// Node.js (Express.js) middleware example
const { Pool } = require('pg');
const pool = new Pool();

// Middleware to be used on authenticated routes
async function setDatabaseContext(req, res, next) {
  // Assume req.user is populated by a JWT validation middleware
  if (!req.user || !req.user['https://myapp.com/claims']) {
    return next(); // Or throw an error
  }

  const client = await pool.connect();
  req.dbClient = client; // Attach client to request for use in route handlers

  try {
    // The JWT claims object is stringified and sent to PostgreSQL
    const claims = JSON.stringify(req.user['https://myapp.com/claims']);

    // IMPORTANT: Use SET LOCAL to scope the setting to the transaction
    await client.query('BEGIN');
    await client.query(`SET LOCAL app.jwt.claims = '${claims.replace(/'/g, "''")}';`);
    
    // Also set user_id for auditing or simple checks
    const userId = req.user.sub;
    await client.query(`SET LOCAL app.user_id = '${userId.replace(/'/g, "''")}';`);
    
    next();
  } catch (err) {
    await client.query('ROLLBACK');
    client.release();
    next(err);
  }
}

// In your route handler, you would use req.dbClient
app.get('/tasks', setDatabaseContext, async (req, res, next) => {
  try {
    const result = await req.dbClient.query('SELECT * FROM tasks LIMIT 100;');
    await req.dbClient.query('COMMIT');
    res.json(result.rows);
  } catch (err) {
    await req.dbClient.query('ROLLBACK');
    next(err);
  } finally {
    if (req.dbClient) {
      req.dbClient.release();
    }
  }
});

This transaction management is crucial. The SET LOCAL commands and the business query must happen within the same transaction. If any part fails, the entire transaction is rolled back.

Step 3: Building Immutable Helper Functions in PL/pgSQL

Now we create a set of helper functions in the database to provide a clean API for our RLS policies to access this session context. These functions will parse the JSONB string we stored.

sql
-- Helper functions to safely access JWT claims

-- Returns the entire claims object as JSONB
CREATE OR REPLACE FUNCTION get_jwt_claims() 
RETURNS jsonb AS $$
BEGIN
    -- Use coalesce to return an empty JSON object if the setting is not present
    -- This prevents errors and allows for unauthenticated access patterns if needed
    RETURN coalesce(current_setting('app.jwt.claims', true), '{}')::jsonb;
EXCEPTION
    -- In case of invalid JSON, return an empty object
    WHEN invalid_text_representation THEN
        RETURN '{}'::jsonb;
END;
$$ LANGUAGE plpgsql STABLE;

-- Returns the current user's organization role
CREATE OR REPLACE FUNCTION get_org_role() 
RETURNS text AS $$
    SELECT get_jwt_claims() ->> 'org_role';
$$ LANGUAGE sql STABLE;

-- Checks if the user is an organization admin
CREATE OR REPLACE FUNCTION is_org_admin() 
RETURNS boolean AS $$
    SELECT get_org_role() = 'ADMIN';
$$ LANGUAGE sql STABLE;

-- Returns an array of project UUIDs the user has explicit access to
CREATE OR REPLACE FUNCTION get_accessible_project_ids() 
RETURNS uuid[] AS $$
    SELECT ARRAY(
        SELECT (value ->> 'id')::uuid
        FROM jsonb_array_elements(get_jwt_claims() -> 'projects')
    );
$$ LANGUAGE sql STABLE;

-- Returns the current user's ID from the session
CREATE OR REPLACE FUNCTION get_current_user_id()
RETURNS uuid AS $$
    SELECT current_setting('app.user_id', true)::uuid;
$$ LANGUAGE sql STABLE;

A Note on Function Volatility (STABLE):

Marking these functions as STABLE is a critical performance optimization. It tells the PostgreSQL query planner that the function's result is consistent within a single query scan. This allows the planner to evaluate the function once and cache its result, rather than re-evaluating it for every row. Since our app.jwt.claims setting is fixed for the duration of the transaction (and thus the query), STABLE is the correct volatility.

Step 4: The New, High-Performance RLS Policy

With our helper functions in place, we can now write a clean, declarative, and highly performant RLS policy for the tasks table.

sql
-- First, drop the old, inefficient policy
DROP POLICY IF EXISTS select_tasks_naive ON tasks;

-- The new, dynamic policy for SELECT
CREATE POLICY select_tasks_dynamic ON tasks FOR SELECT
USING (
    EXISTS (
        SELECT 1
        FROM projects p
        WHERE p.id = tasks.project_id
        AND (
            -- Condition 1: User is an org admin in the project's organization
            (is_org_admin() AND p.org_id = (get_jwt_claims() ->> 'org_id')::uuid)
            OR
            -- Condition 2: User has explicit access to this project
            p.id = ANY(get_accessible_project_ids())
        )
    )
);

-- Always remember to enable RLS on the table
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

This policy is a world away from the naive version. The subquery still exists, but the expensive permission joins are gone. The USING clause now contains calls to our STABLE functions. The query planner is smart enough to evaluate is_org_admin(), get_jwt_claims(), and get_accessible_project_ids() once, and then use the results to inform its plan. The check per row becomes a simple comparison against these pre-computed values, which is orders of magnitude faster.

Performance Benchmark: Naive vs. Dynamic

Running EXPLAIN ANALYZE SELECT count(*) FROM tasks; on a table with 1 million tasks would show a dramatic difference:

* Naive Policy: The plan would likely involve a Nested Loop, where for each task, it performs index scans on project_members and organization_members. The execution time would be proportional to the number of tasks, likely taking multiple seconds or even minutes.

Dynamic Policy: The plan will show the get_ functions being called once. The core of the plan will be a much more efficient scan on tasks and projects, with a simple filter condition. The execution time will be in milliseconds, scaling much more gracefully.

Advanced Scenarios and Edge Cases

This pattern is powerful, but real-world systems require handling more complexity.

1. Policies for Write Operations (`INSERT`, `UPDATE`, `DELETE`)

RLS isn't just for reads. You must define policies for write operations using the WITH CHECK clause, which enforces that any new or modified rows must conform to the policy.

sql
-- Policy to allow task creation
CREATE POLICY insert_tasks_dynamic ON tasks FOR INSERT
WITH CHECK (
    EXISTS (
        SELECT 1
        FROM projects p
        WHERE p.id = tasks.project_id
        AND (
            (is_org_admin() AND p.org_id = (get_jwt_claims() ->> 'org_id')::uuid)
            OR
            -- More granular: only allow EDITORs to create tasks
            (p.id, 'EDITOR') IN (SELECT (value ->> 'id')::uuid, value ->> 'role' FROM jsonb_array_elements(get_jwt_claims() -> 'projects'))
        )
    )
);

-- Policy for updates and deletes
-- Let's say only Org Admins or Project Editors can modify/delete tasks
CREATE POLICY update_delete_tasks_dynamic ON tasks FOR ALL -- Covers UPDATE and DELETE
USING (
    -- The USING clause is the same as our SELECT policy for visibility
    EXISTS (SELECT 1 FROM projects p WHERE p.id = tasks.project_id AND ((is_org_admin() AND p.org_id = (get_jwt_claims() ->> 'org_id')::uuid) OR p.id = ANY(get_accessible_project_ids())))
)
WITH CHECK (
    -- The WITH CHECK clause ensures they can't move a task to a project they don't have edit access to
    EXISTS (
        SELECT 1
        FROM projects p
        WHERE p.id = tasks.project_id
        AND (
            (is_org_admin() AND p.org_id = (get_jwt_claims() ->> 'org_id')::uuid)
            OR
            (p.id, 'EDITOR') IN (SELECT (value ->> 'id')::uuid, value ->> 'role' FROM jsonb_array_elements(get_jwt_claims() -> 'projects'))
        )
    )
);

Notice that for UPDATE, the USING clause determines which rows are visible to the UPDATE statement, and the WITH CHECK clause validates the resulting state of the row after the update. They are both essential for security.

2. Handling Permissions Too Complex for a JWT

JWTs have a practical size limit (usually 4-8KB in most web servers). If your permission model is so granular that a user's access list could become huge, embedding it all in the token is not feasible. In this scenario, we use a hybrid approach.

The JWT contains the stable identifiers: user_id, org_id.

Inside the database, we create a function that, once per transaction, performs the complex permission lookup and materializes the results into a temporary table, scoped to the session.

sql
CREATE OR REPLACE FUNCTION cache_user_permissions() 
RETURNS void AS $$
BEGIN
    -- Check if the cache already exists for this transaction to make the function idempotent
    IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'session_permissions_cache' AND relpersistence = 't') THEN
        RETURN;
    END IF;

    -- Create a temporary table that is dropped at the end of the session
    CREATE TEMP TABLE session_permissions_cache (
        project_id UUID PRIMARY KEY,
        role project_role
    ) ON COMMIT DROP;

    -- Populate the cache with a single, complex query
    INSERT INTO session_permissions_cache (project_id, role)
    SELECT pm.project_id, pm.role
    FROM project_members pm
    WHERE pm.user_id = get_current_user_id();

    -- Add any other complex permission logic here, e.g., permissions inherited from teams

END;
$$ LANGUAGE plpgsql VOLATILE; -- VOLATILE because it has side effects (creates a table)

Now, our RLS policy becomes even simpler and faster. It just needs to check this temporary table. We call cache_user_permissions() once at the start of our transaction from the application layer.

javascript
// In the Node.js middleware, after setting the context:
await client.query('SELECT cache_user_permissions();');
sql
-- RLS policy using the temporary cache table
CREATE POLICY select_tasks_cached ON tasks FOR SELECT
USING (
    EXISTS (
        SELECT 1
        FROM projects p
        WHERE p.id = tasks.project_id
        AND (
            (is_org_admin() AND p.org_id = (get_jwt_claims() ->> 'org_id')::uuid)
            OR
            EXISTS (SELECT 1 FROM session_permissions_cache spc WHERE spc.project_id = p.id)
        )
    )
);

This pattern gives us the best of both worlds: a small, verifiable JWT and the ability to perform a single, efficient, complex permission lookup that is then reused for all subsequent queries within the transaction.

3. Debugging and Tooling

Debugging RLS can be challenging because the filtering is implicit. Two tools are essential:

  • EXPLAIN: Always use EXPLAIN (ANALYZE, BUFFERS) on your queries. The query plan will include a Filter or Subquery Scan node corresponding to your RLS policy. Analyzing this node is key to diagnosing performance issues.
  • Impersonation: To test policies directly in psql, you can simulate the application's behavior:
  • sql
        BEGIN;
        -- Set the context as the application would
        SET LOCAL app.jwt.claims = '{"org_id": "...", "org_role": "MEMBER", "projects": [{"id": "..."}]}';
        SET LOCAL app.user_id = '...';
    
        -- To test a specific user's view, you can set the role if you're not using JWT-based roles
        -- SET ROLE some_user_role;
    
        -- Now run your query and see what the user would see
        SELECT * FROM tasks;
    
        -- Check the query plan
        EXPLAIN ANALYZE SELECT * FROM tasks;
    
        ROLLBACK; -- Clean up

    Conclusion: A Robust Framework for Scalable Security

    By moving from static, join-heavy RLS policies to a dynamic, session-context-driven approach, we achieve a significant leap in performance, maintainability, and security. This pattern centralizes complex permission logic into a verifiable artifact (the JWT) and a clean set of database helper functions. The RLS policies themselves become simple, declarative statements that are easy to reason about and audit.

    This architecture provides a robust foundation for building sophisticated SaaS applications on PostgreSQL. It decouples the application's business logic from the database's security enforcement layer, ensuring that data access rules are applied consistently and efficiently, no matter how the data is accessed. While it introduces a level of setup complexity, the long-term benefits for performance and security in a complex, multi-tenant environment are undeniable.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles