Advanced PostgreSQL RLS Patterns for Complex SaaS Permissions

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 Ceiling of Simple RLS in Multi-Tenant Architectures

For many SaaS applications, the initial foray into PostgreSQL's Row-Level Security (RLS) is a revelation. The ability to enforce data access rules at the database layer, transparently to the application, feels like a silver bullet for multi-tenancy. The canonical example is ubiquitous:

sql
-- Enable RLS on a table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- A simple policy based on a session variable
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

In the application, before each transaction, you execute SET app.current_tenant_id = '...'. This pattern works beautifully for simple isolation where a user belongs to exactly one tenant and all data is neatly partitioned by a tenant_id.

However, for senior engineers building sophisticated SaaS platforms, this model shatters against the reality of complex permission models:

  • Multiple Roles & Granular Permissions: A user isn't just a 'member' of a tenant. They might be an org_admin in one organization, a project_editor in a specific project, and a viewer in another. A single tenant_id check is woefully inadequate.
  • Hierarchical Access: An organization_admin should implicitly have access to all projects within that organization. A regional_manager might need access to data from multiple organizations within their region. Simple equality checks fail to model these parent-child relationships.
  • Object-Level Permissions: What about resources shared explicitly with a user, irrespective of their primary tenant or role? For instance, a specific document is shared with user_A, who is external to the document's organization.
  • Performance Degradation: As policies become more complex, involving sub-selects or function calls, they can become a performance minefield, confusing the query planner and leading to disastrous execution plans.
  • This article bypasses the introductory material and dives straight into a production-grade pattern for implementing a flexible, performant, and secure RLS system capable of handling the complex authorization logic required by modern SaaS applications.

    The Core Pattern: A Centralized, JWT-Driven Permissions Context

    Instead of scattering permission logic across dozens of simplistic policies, we will centralize it. The core idea is to use a single, well-defined transaction setup routine that consumes a user's identity from a JWT and establishes a rich, queryable context for the duration of the database session. This context will inform a set of smarter, more powerful RLS policies.

    Step 1: Designing the Authorization Schema

    First, we need a schema that can represent our complex permissions. A role-based access control (RBAC) model is a solid foundation.

    sql
    -- Create a dedicated schema for our authorization logic
    CREATE SCHEMA app_auth;
    
    -- Define available roles in the system
    CREATE TYPE app_auth.role_type AS ENUM ('org_admin', 'project_editor', 'project_viewer');
    
    -- Users table (likely managed by your auth provider, but needed for FKs)
    CREATE TABLE public.users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid() );
    
    -- Organizations table
    CREATE TABLE public.organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid() );
    
    -- Projects table, belonging to an organization
    CREATE TABLE public.projects (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
        name TEXT NOT NULL
    );
    
    -- The key table: linking users to roles within a specific context (organization)
    CREATE TABLE app_auth.user_roles (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
        organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
        role app_auth.role_type NOT NULL,
        UNIQUE (user_id, organization_id, role) -- A user has a role once per org
    );
    
    -- Index for efficient lookups
    CREATE INDEX ON app_auth.user_roles (user_id);

    This schema allows a user to hold different roles in different organizations, providing the granularity we need.

    Step 2: The JWT as the Source of Truth

    Your authentication service (e.g., Auth0, Clerk, Keycloak, or a custom one) should issue a JWT upon successful login. For our RLS system to work, this JWT must contain the essential, immutable identity claims. We will not put volatile permission data in the JWT itself, but rather the identifiers needed to look them up.

    Sample JWT Payload:

    json
    {
      "sub": "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // The user's unique ID
      "email": "[email protected]",
      "app_metadata": {
        "active_org_id": "org_1234567890abcdef"
      },
      "iat": 1678886400,
      "exp": 1678890000
    }

    The sub (subject) claim is the user's ID. We've also included a custom claim, active_org_id, which the application frontend can set to indicate the user's current context. This is crucial for UI-driven context switching (e.g., changing the selected organization in a dropdown).

    Step 3: The Transaction-Scoped Context Function

    This is the heart of our system. We'll create a single PL/pgSQL function that the application backend must call at the beginning of every database transaction for an authenticated user. This function will parse the JWT, validate it, and set multiple session-level configuration variables using set_config().

    sql
    CREATE OR REPLACE FUNCTION public.set_app_context(jwt_payload JSONB)
    RETURNS void
    LANGUAGE plpgsql
    AS $$
    DECLARE
        user_id_from_jwt UUID;
        active_org_id_from_jwt UUID;
        user_roles app_auth.role_type[];
    BEGIN
        -- Extract claims from the JWT payload. The application layer is responsible
        -- for JWT *verification*. The database layer trusts the payload it's given.
        user_id_from_jwt := (jwt_payload->>'sub')::uuid;
        active_org_id_from_jwt := (jwt_payload->'app_metadata'->>'active_org_id')::uuid;
    
        -- Ensure the user ID is valid
        IF NOT EXISTS (SELECT 1 FROM public.users WHERE id = user_id_from_jwt) THEN
            RAISE EXCEPTION 'User not found';
        END IF;
    
        -- Set the basic, always-available context variables
        -- The 'true' flag makes the setting transaction-local.
        PERFORM set_config('app.current_user_id', user_id_from_jwt::text, true);
    
        -- If an active organization is provided, set more specific context
        IF active_org_id_from_jwt IS NOT NULL THEN
            -- Check if the user is actually a member of the organization they claim to be active in.
            -- This is a critical security check.
            SELECT array_agg(role) INTO user_roles
            FROM app_auth.user_roles
            WHERE user_roles.user_id = user_id_from_jwt
              AND user_roles.organization_id = active_org_id_from_jwt;
    
            IF array_length(user_roles, 1) IS NULL THEN
                RAISE EXCEPTION 'User does not have access to organization %', active_org_id_from_jwt;
            END IF;
    
            PERFORM set_config('app.current_org_id', active_org_id_from_jwt::text, true);
            PERFORM set_config('app.current_roles', array_to_string(user_roles, ','), true);
        ELSE
            -- Clear any potentially stale org-specific settings from a previous transaction in the same session
            PERFORM set_config('app.current_org_id', '', true);
            PERFORM set_config('app.current_roles', '', true);
        END IF;
    
    EXCEPTION
        -- Catch JSON access errors, casting errors, etc.
        WHEN OTHERS THEN
            -- Clear all settings to ensure a failed context setup doesn't leave partial state
            PERFORM set_config('app.current_user_id', '', true);
            PERFORM set_config('app.current_org_id', '', true);
            PERFORM set_config('app.current_roles', '', true);
            RAISE INFO 'Error setting app context: %', SQLERRM;
            RAISE;
    END;
    $$;

    Now, your application's database connection logic for authenticated routes looks like this (example in Node.js with pg):

    javascript
    async function executeQueryAsUser(userJwtPayload, query, params) {
      const client = await pool.getClient();
      try {
        await client.query('BEGIN');
        // Set the context for this transaction
        await client.query('SELECT public.set_app_context($1)', [userJwtPayload]);
        
        const result = await client.query(query, params);
        
        await client.query('COMMIT');
        return result.rows;
      } catch (err) {
        await client.query('ROLLBACK');
        throw err;
      } finally {
        client.release();
      }
    }

    Section 3: Implementing Hierarchical and Role-Based Policies

    With our rich context (app.current_user_id, app.current_org_id, app.current_roles), we can now write intelligent RLS policies. The key is to avoid putting complex logic directly into the USING clause. Instead, we'll encapsulate it in helper functions.

    The `has_role` Helper Function

    First, a simple function to check if the current user has a specific role. This is more readable and maintainable than parsing the comma-separated string in every policy.

    sql
    CREATE OR REPLACE FUNCTION app_auth.has_role(role_to_check app_auth.role_type)
    RETURNS boolean
    LANGUAGE sql
    STABLE
    AS $$
        SELECT role_to_check = ANY(string_to_array(current_setting('app.current_roles', true), ',')::app_auth.role_type[]);
    $$;

    The RLS Policy for `projects`

    Now, let's secure the projects table. The logic is: a user can see a project if...

    • They are in the project's parent organization, AND
  • They are an org_admin (hierarchical access), OR
  • They are a project_editor or project_viewer.
  • sql
    -- First, enable RLS on the projects table
    ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
    -- Force RLS for the table owner as well, a good security practice
    ALTER TABLE public.projects FORCE ROW LEVEL SECURITY;
    
    -- Now, create the policy
    CREATE POLICY select_projects ON public.projects
    FOR SELECT
    USING (
        -- Rule 1: User must be in the project's organization
        organization_id = (current_setting('app.current_org_id', true))::uuid
        AND (
            -- Rule 2: They are an org_admin
            app_auth.has_role('org_admin')
            -- OR they have a project-level role
            OR app_auth.has_role('project_editor')
            OR app_auth.has_role('project_viewer')
        )
    );

    This policy is already far more powerful than a simple tenant ID check. It correctly models hierarchical access for the org_admin role. An org_admin in organization X will automatically see all projects with organization_id = X, while a project_viewer will be subject to the same check but their permissions will be interpreted differently by the application logic (e.g., UI elements for editing will be disabled).

    Section 4: Performance Deep Dive and Optimization Patterns

    The previous pattern is clean, but every row scan on the projects table will invoke current_setting() and app_auth.has_role(). For hot tables, this can introduce overhead. The PostgreSQL query planner is smart, but function calls, especially those marked STABLE instead of IMMUTABLE, can sometimes act as optimization fences.

    Let's analyze a query:

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

    The query plan will show a filter condition that includes our RLS policy. For large tables, the overhead of invoking these functions for every potential row can become significant.

    Optimization Pattern: Pre-calculating Permissions in the Context

    For read-heavy workloads where permission checks are complex, we can shift the cost. Instead of calculating permissions for each row, we can pre-calculate the set of accessible resources once, when we set the context.

    Let's imagine a more complex scenario where project access is not just role-based but also defined in a project_members table:

    sql
    CREATE TABLE app_auth.project_members (
        project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
        user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
        PRIMARY KEY (project_id, user_id)
    );

    Our RLS policy would now need to join or subquery this table, which can be slow. The alternative is to pre-calculate the list of accessible project IDs.

    1. Modify set_app_context to cache accessible IDs:

    sql
    -- (Inside the set_app_context function, after setting other variables)
    DECLARE
        -- ... other variables
        accessible_project_ids_array UUID[];
    BEGIN
        -- ... previous context setting logic ...
    
        IF active_org_id_from_jwt IS NOT NULL THEN
            -- ... logic to set org_id and roles ...
    
            -- NEW: Pre-calculate all accessible project IDs for the user in this org
            SELECT array_agg(p.id) INTO accessible_project_ids_array
            FROM public.projects p
            WHERE p.organization_id = active_org_id_from_jwt
              AND (
                  -- User is an org_admin, gets all projects in the org
                  app_auth.has_role('org_admin')
                  
                  -- OR user is an explicit member of the project
                  OR EXISTS (
                      SELECT 1 FROM app_auth.project_members pm
                      WHERE pm.project_id = p.id AND pm.user_id = user_id_from_jwt
                  )
              );
    
            -- Store this array as a comma-separated string in a session variable
            PERFORM set_config('app.accessible_project_ids', array_to_string(accessible_project_ids_array, ','), true);
        ELSE
            PERFORM set_config('app.accessible_project_ids', '', true);
        END IF;
    
        -- ... rest of the function
    END;

    2. Simplify the RLS Policy:

    Now, the RLS policy becomes dramatically simpler and faster for the query planner to optimize. It's just a direct check against a pre-computed list.

    sql
    -- Drop the old policy
    DROP POLICY select_projects ON public.projects;
    
    -- Create the new, optimized policy
    CREATE POLICY select_projects_optimized ON public.projects
    FOR SELECT
    USING (
        id = ANY(string_to_array(current_setting('app.accessible_project_ids', true), ',')::UUID[])
    );

    Trade-off Analysis:

    * Pros: Query-time performance for SELECTs on projects is significantly improved. The RLS check is now a simple, index-friendly array lookup.

    * Cons: The initial set_app_context call is now more expensive. It performs a potentially complex query to gather all accessible project IDs.

    This trade-off is almost always worth it. The context is set once per transaction, while the RLS policy is applied to every query within that transaction. You are amortizing the cost of the permission check.

    Section 5: Edge Cases and Production Hardening

    Implementing RLS in production requires handling several critical edge cases.

    1. Bypassing RLS for Service Roles and Migrations

    Your background workers, database migration tools, and superuser admins need to operate on the entire dataset, ignoring RLS. The BYPASSRLS attribute is designed for this.

    sql
    -- Create a role for your application's backend service/migration runner
    CREATE ROLE service_role WITH LOGIN PASSWORD '...';
    
    -- This is the critical part
    ALTER ROLE service_role SET BYPASSRLS = on;
    
    -- Grant it necessary permissions
    GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role;
    GRANT ALL ON ALL TABLES IN SCHEMA app_auth TO service_role;

    When connecting to the database with the service_role, RLS policies will not be applied. Guard the credentials for this role with extreme care.

    2. The `SECURITY DEFINER` and `search_path` Pitfall

    Sometimes, you need RLS helper functions to run with the permissions of the user who defined the function, not the one who invoked it. This is done with the SECURITY DEFINER clause. For example, a function that checks permissions might need to read the app_auth.user_roles table, which the calling user may not have direct SELECT access to.

    sql
    CREATE OR REPLACE FUNCTION app_auth.is_org_admin(user_id_to_check UUID, org_id_to_check UUID)
    RETURNS boolean
    LANGUAGE sql
    SECURITY DEFINER
    -- VULNERABLE: Lacks a secure search_path
    AS $$
        SELECT EXISTS (
            SELECT 1 FROM app_auth.user_roles
            WHERE user_id = user_id_to_check
              AND organization_id = org_id_to_check
              AND role = 'org_admin'
        );
    $$;

    The Danger: If a malicious user can create a function or table in their own schema (e.g., malicious_user.user_roles), and their schema is in the search_path before app_auth, this SECURITY DEFINER function could be tricked into executing against their fake table.

    The Fix: Always set a secure search_path on SECURITY DEFINER functions.

    sql
    ALTER FUNCTION app_auth.is_org_admin(UUID, UUID) SET search_path = app_auth, public;

    This forces the function to only look for tables in the app_auth and public schemas, in that order, preventing hijacking.

    3. Policies for `INSERT` and `UPDATE` with `WITH CHECK`

    RLS isn't just for reading data (USING). You must also prevent users from writing data into a context they don't have access to. This is done with a WITH CHECK clause.

    For example, we must prevent a user from creating a project in an organization they don't belong to, or moving a project from one organization to another.

    sql
    CREATE POLICY insert_projects ON public.projects
    FOR INSERT
    WITH CHECK (
        -- The user must be an org_admin in the organization they are creating a project for.
        organization_id = (current_setting('app.current_org_id', true))::uuid
        AND app_auth.has_role('org_admin')
    );
    
    CREATE POLICY update_projects ON public.projects
    FOR UPDATE
    USING (
        -- The USING clause still governs which rows are visible to update.
        -- We can reuse the select policy here.
        true
    )
    WITH CHECK (
        -- The WITH CHECK clause validates the *new* row data.
        -- Prevent users from moving a project to a different organization.
        organization_id = (current_setting('app.current_org_id', true))::uuid
        AND app_auth.has_role('org_admin')
    );

    Note that the UPDATE policy has both a USING clause (which rows can be targeted by the UPDATE) and a WITH CHECK clause (is the resulting row valid?). Often, the logic is similar or identical.

    4. Testing RLS Policies

    Testing RLS is notoriously difficult because the effects are tied to the database session state. A robust strategy involves using a test framework that can manage transactions and impersonate different users.

    Here's a conceptual example using pg-tap, a popular PostgreSQL testing framework:

    sql
    -- tests/rls_projects.sql
    BEGIN;
    
    SELECT plan(3);
    
    -- Setup: Create users, orgs, roles, and projects
    INSERT INTO users (id) VALUES ('user_admin'), ('user_viewer'), ('user_other_org');
    INSERT INTO organizations (id) VALUES ('org_a'), ('org_b');
    INSERT INTO app_auth.user_roles (user_id, organization_id, role) VALUES
        ('user_admin', 'org_a', 'org_admin'),
        ('user_viewer', 'org_a', 'project_viewer');
    INSERT INTO projects (id, organization_id, name) VALUES
        ('proj_1_a', 'org_a', 'Project 1 in Org A'),
        ('proj_2_b', 'org_b', 'Project 2 in Org B');
    
    -- Test Case 1: Admin in Org A can see project in Org A
    -- Impersonate the admin user
    SELECT public.set_app_context('{"sub": "user_admin", "app_metadata": {"active_org_id": "org_a"}}');
    SELECT is(
        (SELECT count(*)::int FROM projects),
        1,
        'Admin in Org A should see 1 project'
    );
    
    -- Test Case 2: Viewer in Org A can see project in Org A
    SELECT public.set_app_context('{"sub": "user_viewer", "app_metadata": {"active_org_id": "org_a"}}');
    SELECT is(
        (SELECT count(*)::int FROM projects),
        1,
        'Viewer in Org A should see 1 project'
    );
    
    -- Test Case 3: User from another org sees nothing
    SELECT public.set_app_context('{"sub": "user_other_org", "app_metadata": {"active_org_id": "org_b"}}');
    SELECT is(
        (SELECT count(*)::int FROM projects WHERE organization_id = 'org_a'),
        0,
        'User from Org B should see 0 projects from Org A'
    );
    
    SELECT * FROM finish();
    ROLLBACK;

    This approach allows you to write repeatable, isolated tests that explicitly set the user context for each assertion, ensuring your policies behave exactly as expected under different conditions.

    Conclusion: RLS as a Strategic Advantage

    By moving beyond simplistic tenant-ID checks and embracing a centralized, context-aware RLS framework, you can build a powerful and resilient authorization layer directly within PostgreSQL. This pattern—combining a structured RBAC schema, a JWT-driven context function, performance-optimized helper functions, and rigorous testing—provides a robust foundation for complex SaaS applications.

    While the initial setup is more involved than a simple policy, the long-term benefits are immense:

    * Centralized Logic: Authorization rules live in one place, reducing the risk of inconsistent or missing checks in the application layer.

    * Automatic Enforcement: Every query, whether from your API, a reporting tool, or a direct database connection, is subject to the same security rules.

    * Performance at Scale: By consciously designing policies and using optimization patterns like permission caching, RLS can perform exceptionally well even under heavy load.

    Row-Level Security is not just a feature; it is an architectural pattern. For senior engineers tasked with building secure, scalable, and maintainable multi-tenant systems, mastering these advanced RLS techniques is a critical and strategic skill.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles