How to Test Row-Level Security (RLS) in Postgres

Dan Lynch

Dan Lynch

Nov 14, 2025

lesson header image

Previously: In Spinning Up Temporary Testing Databases for Postgres, we created a pets module with a schema and table. Now let's add RLS policies and test them.

Row-Level Security (RLS) is Postgres's most powerful security feature, but it's also the hardest to test. You need to simulate different users, different roles, and verify that policies actually enforce the access control you expect.

In this lesson, you'll add RLS policies to your pets module and test them with pgsql-test by switching between user contexts.

Prerequisites

See Prerequisites. Requires: Complete Spinning Up Temporary Testing Databases for Postgres.

Why Test RLS?

RLS secures multi-tenant applications by ensuring users only access their own data. Without testing, you risk data leaks between users or overly restrictive policies that block legitimate access.

pgsql-test makes RLS testing simple with the setContext() method—switch between users and roles to verify your security policies work correctly.

Adding RLS to Pets

Add owner_id column and RLS policies:

pgpm add schemas/pets/tables/pets/add_rls --requires schemas/pets/tables/pets

Edit deploy/schemas/pets/tables/pets/add_rls.sql:

-- Deploy: schemas/pets/tables/pets/add_rls
-- requires: schemas/pets/tables/pets

-- Add owner_id column
ALTER TABLE pets.pets
  ADD COLUMN owner_id UUID;

-- Enable RLS
ALTER TABLE pets.pets ENABLE ROW LEVEL SECURITY;

-- Grant schema usage
GRANT USAGE ON SCHEMA pets TO anonymous;
GRANT USAGE ON SCHEMA pets TO authenticated;

-- Grant permissions
GRANT SELECT ON TABLE pets.pets TO anonymous;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE pets.pets TO authenticated;

-- Policy: Anyone can view all pets
CREATE POLICY pets_select_all ON pets.pets
  FOR SELECT
  USING (true);

-- Policy: Users can only insert pets they own
CREATE POLICY pets_insert_own ON pets.pets
  FOR INSERT
  WITH CHECK (owner_id = current_setting('jwt.claims.user_id', true)::UUID);

-- Policy: Users can only update their own pets
CREATE POLICY pets_update_own ON pets.pets
  FOR UPDATE
  USING (owner_id = current_setting('jwt.claims.user_id', true)::UUID);

-- Policy: Users can only delete their own pets
CREATE POLICY pets_delete_own ON pets.pets
  FOR DELETE
  USING (owner_id = current_setting('jwt.claims.user_id', true)::UUID);

Understanding Test Database Clients

pgsql-test provides two database clients:

  • pg - Postgres superuser for setup/teardown operations
  • db - User client for testing with authentication roles

Use pg to insert test data as superuser. Use db with setContext() to test RLS policies.

Testing RLS Policies

Create __tests__/rls.test.ts:

import { getConnections, PgTestClient } from 'pgsql-test';

const user1 = '11111111-1111-1111-1111-111111111111';
const user2 = '22222222-2222-2222-2222-222222222222';
const user3 = '33333333-3333-3333-3333-333333333333';
const user4 = '44444444-4444-4444-4444-444444444444';
const user5 = '55555555-5555-5555-5555-555555555555';

let pg: PgTestClient;
let db: PgTestClient;
let teardown: () => Promise<void>;

beforeAll(async () => {
  ({ pg, db, teardown } = await getConnections());

  // Insert test data as superuser
  await pg.query(`
    INSERT INTO pets.pets (name, species, age, owner_id) VALUES
    ('Public Pet', 'dog', 3, $1)
  `, [user1]);
});

afterAll(async () => await teardown());
beforeEach(async () => await db.beforeEach());
afterEach(async () => await db.afterEach());

it('anonymous users can view all pets', async () => {
  // Switch to anonymous role
  db.setContext({ role: 'anonymous' });

  const result = await db.query('SELECT * FROM pets.pets');
  expect(result.rows).toHaveLength(1);
  expect(result.rows[0].name).toBe('Public Pet');
});

it('anonymous users cannot insert pets', async () => {
  db.setContext({ role: 'anonymous' });

  await expect(
    db.query(`
      INSERT INTO pets.pets (name, species, owner_id)
      VALUES ('Hacker Pet', 'cat', $1)
    `, [user2])
  ).rejects.toThrow(/permission denied/);
});

The setContext() method on db sets the role and user context for the current transaction. All subsequent queries run with that authentication context.

Using Watch Mode for Rapid Testing

For faster feedback, use watch mode. First, initialize git if you haven't already:

cd /path/to/my-database-project
git init .

Then run tests in watch mode:

cd packages/pets
pnpm test:watch

Watch mode re-runs tests automatically when you save changes, giving you instant feedback as you develop.

Note: pgpm init defaults to Jest, but pgsql-test is test-framework agnostic—you can use it with Mocha, Vitest, or any other test runner.

Jest watch mode commands:

  • Press p to filter by filename pattern
  • Press t to filter by test name pattern
  • Press a to run all tests
  • Press q to quit watch mode

Testing Authenticated User Access

Add tests for authenticated users managing their own pets:

it('authenticated users can insert pets they own', async () => {
  // Switch to authenticated user
  db.setContext({
    role: 'authenticated',
    'jwt.claims.user_id': user3
  });

  await db.query(`
      INSERT INTO pets.pets (name, species, age, owner_id)
      VALUES ('My Pet', 'cat', 2, $1)
    `, [user3]);

  const result = await db.query('SELECT * FROM pets.pets WHERE name = $1', ['My Pet']);
  expect(result.rows).toHaveLength(1);
  expect(result.rows[0].owner_id).toBe(user3);
});

it('users cannot insert pets for other users', async () => {
  db.setContext({
    role: 'authenticated',
    'jwt.claims.user_id': user4
  });

  await expect(
    db.query(`
        INSERT INTO pets.pets (name, species, owner_id)
        VALUES ('Not My Pet', 'bird', $1)
      `, [user5])
  ).rejects.toThrow(/new row violates row-level security policy/);
});

Run the tests:

pnpm test

Key Takeaways

  • pg client - Use for superuser operations (setup/teardown)
  • db client - Use for testing with authentication roles
  • setContext() sets authentication context for the current transaction
  • Role switching is automatic and scoped to the transaction
  • Use pgpm add to add RLS policies as proper database changes
  • owner_id with jwt.claims.user_id enables user-based access control
  • Test isolation ensures RLS context doesn't bleed between tests

What's Next

You've added RLS policies to your pets module and tested basic user access scenarios. In the next lesson, we'll explore advanced multi-user testing scenarios, simulating malicious attempts and testing complex user interactions.