DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

Test RLS Policies Safely with Neon Branching

By Cenk Kurtoglu, Supabase RLS security auditor (https://rls.cenkkurtoglu.com) and creator of the Supabase Migration Rescue Kit

🪤 The Hook: Why Testing RLS on Production Is a Nightmare

Row-level security (RLS) is the gatekeeper of data privacy in Postgres-backed applications. Get it wrong, and a single INSERT or SELECT can leak every user’s records to the world. Yet many teams still test RLS changes directly on production, or worse, spin up a static copy of the database, apply policies, and hope the test environment mirrors production semantics.

Two horror patterns repeat across the industry:

  1. The “Just This Once” Prod Edit – A developer runs ALTER POLICY ... on the production database during a sprint, verifies with a quick SELECT using the anon key, and commits. The policy works in that session, but an edge case involving JWT claims, session timeouts, or SET role = … flies under the radar. Twelve hours later, a user reports that their ride history is visible to everyone.

  2. The Stale Copy Gambit – A temporary database is cloned from a production snapshot. Policies are applied, tests pass, and the branch is discarded. The problem? The clone is often minutes or hours stale. Business logic changes, new columns, or recent migrations never make it into the test surface. You’re certifying policies against an outdated data model.

Both patterns share a root cause: RLS policies are context-dependent. They reference current_user, auth.jwt() →> 'role', and application-specific functions. Testing them outside a live, version-controlled workflow is a gamble you can’t afford.

Enter Neon database branching. In the next sections, we’ll show how to leverage copy-on-write (CoW) branching to test RLS policies in an isolated, production-sync environment that spins up in seconds, costs near-nothing, and integrates cleanly into CI pipelines.

🌿 Neon Branching Explained

Neon is a Postgres-compatible, serverless platform that treats databases like git repositories. Each branch is a full Postgres instance with its own data directory, built on top of CoW storage.

Feature Neon Branch
Creation neon branch create <name> — seconds
CoW Mechanics Only changed pages are copied; initial state shares storage with the parent
Cost Storage proportional to differences from parent; compute charged per-second when active
Isolation Full network endpoint; independent postgres user, search_path, and pg_hba.conf
Promotion Merge or rebase back to main when tests pass

A Neon branch behaves like a regular Postgres instance. You can psql into it, run ALTER POLICY, insert test rows, and even connect via the anon JWT key your Supabase project uses. Because it’s a true Postgres fork, RLS behaves identically to production—no mocks, no stubs.

👥 The Two-User Test Methodology

Before we touch SQL, let’s define the test framework. We’ll use two logical users, each with a distinct JWT role, to validate that RLS policies enforce isolation correctly.

User A: The Policy Subject

  • Role: anon or a regular authenticated user
  • JWT claim: {"role": "user"} or {"role": "passenger"}
  • Goal: Verify that this user can only see/modify their own rows.

User B: The Policy Overlord / Admin

  • Role: authenticated with role = 'admin' or a service_role key
  • JWT claim: {"role": "admin"}
  • Goal: Verify that admins bypass row-level checks (or that the policy correctly restricts them, depending on your design).

The Isolation Check

For a rides table with columns id, user_id, status, created_at, a typical RLS policy might look like:

CREATE POLICY "users_view_own_rides" ON rides
FOR SELECT
TO anon, authenticated
USING (auth.role() = 'admin' OR user_id = auth.uid());
Enter fullscreen mode Exit fullscreen mode

Another policy might restrict inserts:

CREATE POLICY "users_insert_own_rides" ON rides
FOR INSERT
TO anon, authenticated
WITH CHECK (user_id = auth.uid());
Enter fullscreen mode Exit fullscreen mode

During testing, we’ll execute the same queries as the respective users and assert the row count returns. If User A sees User B’s rows, the policy is broken. If User B (as admin) can’t perform an action they should, the policy is over-restrictive.

🛠️ Step-by-Step: Testing RLS Policies on a Neon Branch

Let’s walk through a complete workflow. We’ll assume you have a Supabase project connected to a Neon main branch.

1. Create a Feature Branch for RLS Testing

# Authenticate to Neon CLI (once)
neon auth login

# Create a branch off main named rls-test-user-isolation
neon branch create rls-test-user-isolation --parent main
Enter fullscreen mode Exit fullscreen mode

Branch creation takes ~5–12 seconds. You’ll get a connection string similar to:

postgres://neondb_owner:password@ep-bold-snow-123.us-east-2.aws.neon.tech/neondb?branch_id=rls-test-user-isolation
Enter fullscreen mode Exit fullscreen mode

2. Apply the New Policies on the Branch

Connect via psql (or your favorite Postgres client) using the branch endpoint:

psql "postgres://neondb_owner:password@ep-bold-snow-123.us-east-2.aws.neon.tech/neondb?branch_id=rls-test-user-isolation"
Enter fullscreen mode Exit fullscreen mode

Apply the policies you want to test. For this example, we’ll add a stricter policy and an admin override:

-- Policy: users can only see their own rides (anon + authenticated)
CREATE POLICY "users_view_own_rides" ON rides
FOR SELECT
TO anon, authenticated
USING (auth.role() = 'admin' OR user_id = auth.uid());

-- Policy: insert must originate from the requesting user
CREATE POLICY "users_insert_own_rides" ON rides
FOR INSERT
TO anon, authenticated
WITH CHECK (user_id = auth.uid());

-- Admin policy: only service_role or explicit admin JWT can bypass
CREATE POLICY "admin_full_access" ON rides
FOR ALL
TO service_role
USING (true);
Enter fullscreen mode Exit fullscreen mode

3. Populate the Table with Test Data (on the branch only)

-- Insert rides for user_id = 1 (Alice) and user_id = 2 (Bob)
INSERT INTO rides (user_id, status) VALUES 
  (1, 'completed'),
  (2, 'pending'),
  (1, 'cancelled');
Enter fullscreen mode Exit fullscreen mode

Note: This data lives only on the branch. Production remains untouched.

4. Test with the anon Key (User A)

In a fresh psql session (or via your Supabase client using the anon public API key), run:

-- As anon, we should only see our own rows
SET app.jwt.claims = '{"role": "user"}';
SELECT count(*) FROM rides WHERE user_id = 1; -- Expected: 2 rows (Alice's rides)
SELECT count(*) FROM rides WHERE user_id = 2; -- Expected: 0 rows (Bob's hidden)
Enter fullscreen mode Exit fullscreen mode

If you’re using the Supabase JS client, the equivalent is setting the global auth token or using supabase.from('rides').select('*') without a user session.

5. Test with Authenticated User B (User A as Bob)

Switch the JWT claims to Bob’s identity:

SET app.jwt.claims = '{"role": "user"}';
-- Bob should only see his own ride
SELECT count(*) FROM rides WHERE user_id = 2; -- Expected: 1 row
Enter fullscreen mode Exit fullscreen mode

6. Test as Admin (User B)

Using the service_role key (or a JWT with role=admin), verify bypass behavior:

-- service_role bypasses RLS entirely
SELECT count(*) FROM rides; -- Expected: 3 rows (all)
Enter fullscreen mode Exit fullscreen mode

If you want to test a stricter admin policy that still respects RLS:

-- As authenticated admin
SET app.jwt.claims = '{"role": "admin"}';
SELECT count(*) FROM rides; -- Depends on policy: may be 3 or filtered
Enter fullscreen mode Exit fullscreen mode

7. Verify Before/After on the Branch

If you previously had a permissive policy and are swapping it for a restrictive one, repeat the same queries on the parent branch (production) to confirm the behavioral diff. Because Neon branches share storage, you can pg_dump the branch and compare schemas, or simply rely on the query results.

8. Promote or Discard

Once you’ve confirmed:

  • User A (anon/authenticated) sees only their rows
  • User B (admin/service_role) behaves as intended
  • No regressions in insert/update/delete flows

You’re ready to promote the branch back to main, or open a PR that applies these policies to production via migration files.

🛠️ CI-Ready Checklist (Bash + SQL)

Integrate the above into a GitHub Actions or GitLab CI job. Here’s a minimal, production-safe checklist:

# .github/workflows/rls-tests.yml
name: Test RLS Policies on Neon Branch
on: [push, pull_request]

jobs:
  rls-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Neon CLI
        run: |
          curl -1sLf 'https://setup.neondr.io/setup.sh' | bash

      - name: Create Neon branch for testing
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          neon auth login --api-key $NEON_API_KEY
          BRANCH_NAME="rls-test-${GITHUB_SHA::8}"
          neon branch create $BRANCH_NAME --parent main
          echo "BRANCH=$BRANCH_NAME" >> $GITHUB_ENV

      - name: Branch connection string
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          CONN=$(neon branch describe ${{ env.BRANCH_NAME }} | grep -oP 'postgres://[^\s]+')
          echo "CONN_STR=$CONN" >> $GITHUB_ENV

      - name: Apply RLS policies (SQL file)
        env:
          CONN_STR: ${{ env.CONN_STR }}
        run: |
          psql "$CONN_STR" -f db/policies/rls_test.sql

      - name: Run isolation tests with anon key
        env:
          CONN_STR: ${{ env.CONN_STR }}
        run: |
          # Test: anon should see only their own rows
          PSQL="psql \"$CONN_STR\""
          # Set anon JWT claims via psql variable or Supabase secret
          $PSQL -c "SELECT count(*) FROM rides WHERE user_id = 1;"  # should be 2
          $PSQL -c "SELECT count(*) FROM rides WHERE user_id = 2;"  # should be 0

      - name: Run admin bypass test
        env:
          CONN_STR: ${{ env.CONN_STR }}
        run: |
          $PSQL -c "SELECT count(*) FROM rides;"  # should be 3 (service_role bypass)

      - name: Destroy branch (cost cleanup)
        run: |
          neon branch delete ${{ env.BRANCH_NAME }} --yes
Enter fullscreen mode Exit fullscreen mode

db/policies/rls_test.sql might contain:

-- Ensure policies exist and are deterministic
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'users_view_own_rides') THEN
    CREATE POLICY "users_view_own_rides" ON rides
    FOR SELECT TO anon, authenticated
    USING (auth.role() = 'admin' OR user_id = auth.uid());
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'users_insert_own_rides') THEN
    CREATE POLICY "users_insert_own_rides" ON rides
    FOR INSERT TO anon, authenticated WITH CHECK (user_id = auth.uid());
  END IF;
END
$$;

-- Seed branch‑local test data (idempotent)
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM rides LIMIT 1) THEN
    INSERT INTO rides (user_id, status) VALUES (1, 'completed'), (2, 'pending'), (1, 'cancelled');
  END IF;
END
$$;
Enter fullscreen mode Exit fullscreen mode

Key CI points:

  • Branch creation is cheap; deletion after the job recovers storage.
  • SQL files are version‑controlled, ensuring “code‑as‑policy” parity.
  • The anon test uses JWT claim manipulation; in real Supabase flow, you’d spin up a test user via supabase auth sign up or use a pre‑generated token with role: "user".
  • Fail the job if any count(*) assertion deviates from the expected seed values.

✅ Merge Only When Green

Once the CI job finishes with all assertions passing, the branch holds a verified RLS configuration. At this point:

  1. Review the policy diff against your migration folder (db/policies/). Ensure the branch’s CREATE POLICY statements match the files you’ll commit.
  2. Squash or rebase the branch into your feature branch, keeping the Neon branch name and test results in the PR description for auditability.
  3. Merge to main. The policies now live in production, but they’ve been vetted in an isolated, production‑identical environment.
  4. Optional: Promote the branch back to main via Neon’s UI or neon branch promote rls-test-user-isolation --parent main if you want the branch to become the new main (use with caution and after a full health check).

If any test fails, the branch stays alive for iteration. You can add debug queries, tweak the USING clause, or adjust JWT claim handling—all without touching production.


👨‍💻 Author Bio

Cenk Kurtoglu is a Supabase RLS security auditor and the creator of the Supabase Migration Rescue Kit, a curated set of scripts and guides for teams migrating from legacy Postgres setups to Supabase while preserving data integrity and row‑level security. He frequently speaks on safe database branching, automated RLS testing, and the pitfalls of cloning production environments. His work has helped over 150 startups de‑risk their Supabase deployments. You can find his RLS tutorials and policy audits at https://rls.cenkkurtoglu.com.

Top comments (0)