Back to Blog

PostgreSQL Row-Level Security: Multi-Tenant Isolation, Session Variables, and Audit Policies

Implement PostgreSQL Row-Level Security for multi-tenant SaaS: RLS policies with session variables, tenant isolation patterns, security-definer functions, and combining RLS with audit logging.

Viprasol Tech Team
November 22, 2026
13 min read

Row-Level Security moves tenant isolation from application code into the database. Instead of adding WHERE tenant_id = $currentTenant to every query and hoping no developer forgets it, RLS enforces it at the storage layer โ€” the database literally returns only rows the current session is allowed to see. A misconfigured application query can't leak data between tenants because the rows aren't visible.

This post covers RLS from first principles: enabling policies, setting session context, using security-definer functions to avoid privilege issues, and combining RLS with audit policies for compliance.

How RLS Works

-- Without RLS: application must remember to filter
SELECT * FROM posts;  -- Returns ALL posts from ALL tenants
-- If app forgets WHERE clause โ†’ data leak

-- With RLS enabled + policy set:
SELECT * FROM posts;  -- Returns ONLY posts where tenant_id = current_setting('app.tenant_id')
-- Even if app omits WHERE clause โ†’ safe

RLS is enforced for all users except superusers and table owners unless you explicitly SET ROW SECURITY = FORCE ROW LEVEL SECURITY or the user bypasses with BYPASSRLS.


1. Basic Tenant Isolation

-- Enable RLS on tables that contain tenant-specific data
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts FORCE ROW LEVEL SECURITY; -- Apply even to table owner

-- Create policy: users see only their tenant's rows
CREATE POLICY tenant_isolation ON posts
  USING (tenant_id = current_setting('app.tenant_id', true)::UUID);

-- The policy applies to SELECT, UPDATE, DELETE, and INSERT by default
-- 'true' in current_setting means "return NULL if not set" instead of throwing

-- Separate policies for different operations (more control)
DROP POLICY tenant_isolation ON posts;

CREATE POLICY posts_select ON posts
  FOR SELECT
  USING (tenant_id = current_setting('app.tenant_id', true)::UUID);

CREATE POLICY posts_insert ON posts
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::UUID);

CREATE POLICY posts_update ON posts
  FOR UPDATE
  USING (tenant_id = current_setting('app.tenant_id', true)::UUID)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::UUID);

CREATE POLICY posts_delete ON posts
  FOR DELETE
  USING (tenant_id = current_setting('app.tenant_id', true)::UUID);

Setting Session Context in the Application

// src/lib/db/rls-client.ts
import { PrismaClient } from '@prisma/client';
import { AsyncLocalStorage } from 'async_hooks';

interface RLSContext {
  tenantId: string;
  userId: string;
  roles: string[];
}

const contextStorage = new AsyncLocalStorage<RLSContext>();

export function runWithTenantContext<T>(
  context: RLSContext,
  fn: () => Promise<T>
): Promise<T> {
  return contextStorage.run(context, fn);
}

export function getCurrentContext(): RLSContext | undefined {
  return contextStorage.getStore();
}

// Prisma extension: set session variables before every query
export const db = new PrismaClient().$extends({
  query: {
    $allModels: {
      async $allOperations({ args, query }) {
        const context = getCurrentContext();

        if (!context) {
          // No context = system/admin operation โ€” RLS policies won't apply
          // (only safe if using a restricted app role, not superuser)
          return query(args);
        }

        // Use $transaction to set session variables + run query atomically
        return db.$transaction(async (tx) => {
          // SET LOCAL applies only to this transaction
          await tx.$executeRaw`
            SELECT set_config('app.tenant_id', ${context.tenantId}, true),
                   set_config('app.user_id', ${context.userId}, true),
                   set_config('app.roles', ${context.roles.join(',')}, true)
          `;

          return query(args);
        });
      },
    },
  },
});

// Middleware: extract tenant context from request headers and run handler
export function withRLS(tenantId: string, userId: string, roles: string[]) {
  return <T>(fn: () => Promise<T>): Promise<T> =>
    runWithTenantContext({ tenantId, userId, roles }, fn);
}

Next.js Route Handler with RLS Context

// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { db, withRLS } from '../../../lib/db/rls-client';

export async function GET(req: NextRequest) {
  const session = await getServerSession();
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Run all DB queries within this handler under the tenant's RLS context
  const posts = await withRLS(
    session.user.tenantId,
    session.user.id,
    session.user.roles
  )(() =>
    // RLS automatically filters to current tenant โ€” no WHERE needed!
    db.post.findMany({ orderBy: { createdAt: 'desc' }, take: 20 })
  );

  return NextResponse.json({ posts });
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

2. Role-Based RLS Policies

-- Policy that combines tenant isolation + role-based access
CREATE POLICY posts_role_policy ON posts
  FOR ALL
  USING (
    tenant_id = current_setting('app.tenant_id', true)::UUID
    AND (
      -- Admins see everything in their tenant
      'admin' = ANY(string_to_array(current_setting('app.roles', true), ','))
      OR
      -- Authors see only their own posts
      (
        'author' = ANY(string_to_array(current_setting('app.roles', true), ','))
        AND author_id = current_setting('app.user_id', true)::UUID
      )
      OR
      -- Viewers see only published posts
      (
        'viewer' = ANY(string_to_array(current_setting('app.roles', true), ','))
        AND status = 'published'
      )
    )
  );

3. Security-Definer Functions for Cross-Tenant Operations

Superuser/admin operations (generating reports, backups, analytics) need to bypass RLS. Use SECURITY DEFINER functions to grant controlled cross-tenant access without exposing credentials.

-- Function runs as its OWNER (who has BYPASSRLS), not the calling user
CREATE OR REPLACE FUNCTION get_tenant_stats(p_tenant_id UUID)
RETURNS TABLE(
  total_posts BIGINT,
  published_posts BIGINT,
  total_users BIGINT
)
LANGUAGE plpgsql
SECURITY DEFINER  -- Executes as function owner, bypasses RLS
SET search_path = public  -- Prevent search_path injection
AS $$
BEGIN
  -- Validate caller has permission to request stats for this tenant
  IF current_setting('app.roles', true) NOT LIKE '%admin%' THEN
    RAISE EXCEPTION 'Permission denied: admin role required';
  END IF;

  RETURN QUERY
  SELECT
    COUNT(*)::BIGINT,
    COUNT(*) FILTER (WHERE status = 'published')::BIGINT,
    (SELECT COUNT(*)::BIGINT FROM users WHERE tenant_id = p_tenant_id)
  FROM posts
  WHERE tenant_id = p_tenant_id;
END;
$$;

-- Grant execute only to the app role (not superuser access)
GRANT EXECUTE ON FUNCTION get_tenant_stats(UUID) TO app_role;

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

4. RLS with Audit Policies

-- Audit table (no RLS โ€” system-level, admin access only)
CREATE TABLE audit_log (
  id          BIGSERIAL   PRIMARY KEY,
  table_name  TEXT        NOT NULL,
  operation   TEXT        NOT NULL, -- INSERT/UPDATE/DELETE
  tenant_id   UUID,
  user_id     UUID,
  row_id      UUID,
  old_data    JSONB,
  new_data    JSONB,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Don't enable RLS on audit_log โ€” it's for system use only
-- Restrict access via GRANT instead:
REVOKE ALL ON audit_log FROM app_role;
GRANT INSERT ON audit_log TO app_role; -- Only INSERT allowed

-- Audit trigger function
CREATE OR REPLACE FUNCTION audit_row_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  INSERT INTO audit_log (
    table_name, operation, tenant_id, user_id, row_id, old_data, new_data
  ) VALUES (
    TG_TABLE_NAME,
    TG_OP,
    COALESCE(
      (CASE WHEN TG_OP = 'DELETE' THEN OLD.tenant_id ELSE NEW.tenant_id END),
      current_setting('app.tenant_id', true)::UUID
    ),
    current_setting('app.user_id', true)::UUID,
    CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END,
    CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END,
    CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END
  );
  RETURN NULL; -- AFTER trigger, return value ignored
END;
$$;

-- Attach audit trigger to sensitive tables
CREATE TRIGGER audit_posts
  AFTER INSERT OR UPDATE OR DELETE ON posts
  FOR EACH ROW EXECUTE FUNCTION audit_row_changes();

CREATE TRIGGER audit_subscriptions
  AFTER INSERT OR UPDATE OR DELETE ON subscriptions
  FOR EACH ROW EXECUTE FUNCTION audit_row_changes();

5. Testing RLS Policies

-- Test RLS as a specific tenant โ€” without needing separate DB connections
BEGIN;

-- Simulate tenant context
SELECT set_config('app.tenant_id', 'tenant-a-uuid', true);
SELECT set_config('app.user_id', 'user-1-uuid', true);
SELECT set_config('app.roles', 'viewer', true);

-- This should return ONLY tenant-a's published posts
SELECT id, title, tenant_id, status FROM posts;

-- This should fail the INSERT check (wrong tenant)
INSERT INTO posts (tenant_id, title, author_id, status)
VALUES ('tenant-b-uuid', 'Hack attempt', 'user-1-uuid', 'published');
-- ERROR: new row violates row-level security policy

ROLLBACK; -- Reset session
// src/lib/db/__tests__/rls.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PrismaClient } from '@prisma/client';

// Use a test database with RLS enabled
const db = new PrismaClient({
  datasourceUrl: process.env.TEST_DATABASE_URL_RLS,
});

describe('Row-Level Security', () => {
  const tenantA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
  const tenantB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';

  it('tenant A cannot see tenant B posts', async () => {
    // Set context to Tenant A
    await db.$executeRaw`
      SELECT set_config('app.tenant_id', ${tenantA}, true),
             set_config('app.roles', 'admin', true)
    `;

    const posts = await db.post.findMany();

    // All returned posts must belong to Tenant A
    for (const post of posts) {
      expect(post.tenantId).toBe(tenantA);
    }
  });

  it('insert with wrong tenant_id is rejected', async () => {
    await db.$executeRaw`
      SELECT set_config('app.tenant_id', ${tenantA}, true),
             set_config('app.roles', 'admin', true)
    `;

    await expect(
      db.post.create({
        data: {
          tenantId: tenantB,  // Wrong tenant!
          title: 'Cross-tenant insert attempt',
          authorId: 'some-user',
          status: 'draft',
        },
      })
    ).rejects.toThrow(); // RLS blocks the INSERT
  });
});

Performance Considerations

-- RLS filters are evaluated per row โ€” ensure the policy expression is indexed
-- Good: tenant_id is almost always indexed already
CREATE INDEX idx_posts_tenant ON posts (tenant_id);

-- Check that RLS policy uses index (look for Bitmap Index Scan or Index Scan)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts
WHERE title ILIKE '%search%';
-- Should show Index Cond: (tenant_id = '...'::uuid) โ€” using the index

-- If you see Seq Scan despite index:
-- 1. Run ANALYZE on the table
-- 2. Check if planner underestimates row count (use pg_stats)
-- 3. Consider partial index on tenant_id + frequently filtered columns

Cost Reference

ApproachImplementation CostOps OverheadSecurity Level
WHERE tenant_id = in app codeLowNoneLow (can be forgotten)
RLS with session variablesMedium ($5Kโ€“15K setup)LowHigh (database-enforced)
RLS + audit triggersMedium-High ($15Kโ€“30K)LowCompliance-ready
Separate schema per tenantHigh ($30Kโ€“100K)HighHighest isolation

See Also


Working With Viprasol

Building a multi-tenant SaaS and relying on application-level WHERE clauses for tenant isolation? One missing filter exposes every customer's data. We implement PostgreSQL Row-Level Security with proper session variable patterns, security-definer functions for admin operations, and audit triggers โ€” giving you database-enforced isolation that can't be bypassed by application bugs.

Talk to our team โ†’ | Explore our cloud solutions โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.