Trending Posts

GitHub Actions pipeline running pgTAP RLS tests for a Supabase database

You wrote pgTAP tests for your RLS policies. They pass on your laptop. You wire them into GitHub Actions, the workflow goes green, and you ship it. Then a tenant reports they can see another tenant's invoices.

Here's what happened: your CI pipeline ran the tests as the postgres superuser, which bypasses row-level security entirely. The tests never touched your policies. A green checkmark told you nothing. This is one of the most common failure modes in Supabase CI/CD, and it's rarely covered properly because most Supabase CI/CD tutorials stop at the pgTAP syntax and never get to the orchestration problems that show up only in a stateless runner.

This post covers the parts that actually break in production: role switching so your tests run against RLS instead of around it, mocking JWT claims when there's no PostgREST request to generate one, caching Docker layers so your pipeline doesn't take four minutes per run, and making sure a failed assertion actually fails the build. If you haven't set up multi-tenant schemas yet, start with the complete guide to multi-tenant database design in Supabase first, since everything here assumes tenant isolation is already the thing you're protecting.

Quick Answer: Supabase CI/CD tests fail silently on RLS because the default connection runs as the postgres superuser, which PostgreSQL exempts from row-level security by design. Fix it by running pgTAP assertions under SET ROLE authenticated with a mocked request.jwt.claims session variable, and add a manual pg_isready polling loop, since supabase start can return success in GitHub Actions before the Postgres container's TCP socket is actually ready.

Table of Contents

  1. Why Your CI Tests Might Be Lying to You
  2. Setting Up the GitHub Actions Workflow File
  3. Solving the Postgres Readiness Race Condition
  4. Role Switching: Testing as authenticated, Not postgres
  5. Mocking JWT Claims for auth.uid() in Headless CI
  6. Seeding Tenant Data Before Running RLS Assertions
  7. Making Sure a Failed pgTAP Test Actually Fails the Build
  8. Caching Docker Layers to Cut Pipeline Time
  9. Quick Answers About Supabase CI/CD Testing
  10. Frequently Asked Questions

Why Your CI Tests Might Be Lying to You

Simply put, a pgTAP test that runs as a database superuser or table owner can't validate RLS, because PostgreSQL's row-level security model exempts superusers and owners from policy enforcement entirely. Your test executes the query, gets the row back, and reports success, but it never went through the USING or WITH CHECK clause you're trying to verify.

This isn't a hypothetical. It's one of the most repeated complaints from engineers setting up Supabase testing: developers running pgTAP through supabase test db discover their tests can't stress-test RLS policies because everything executes as the postgres role by default. The workflow reports green. The security boundary is untested.

The fix isn't complicated once you know it's needed, but almost none of the tutorials that rank for "Supabase CI/CD testing" mention it. They cover the YAML structure and the CLI commands, then leave the actual security validation as an exercise for the reader.

In short: a passing RLS test suite in CI means nothing until you've confirmed it's running as a restricted role, not the database owner.

Setting Up the GitHub Actions Workflow File

Start with a baseline workflow that checks out the repo, installs the Supabase CLI, and starts the local stack. This mirrors the structure in Supabase's own CI documentation, with the additions this post builds on top of.

name: pgTAP RLS Tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  rls-tests:
    runs-on: ubuntu-latest
    env:
      SUPABASE_DB_URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Supabase CLI
        uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Start Supabase local stack
        run: supabase start

      # Readiness loop, role switching, and JWT mocking steps go here
      # (covered in the sections below)

      - name: Run pgTAP RLS tests
        run: supabase test db

On its own, this workflow has the same three problems every top-ranking Supabase CI/CD tutorial has: it doesn't wait for Postgres to actually be ready before testing, it doesn't drop out of the superuser role, and it doesn't cache anything. The next sections fix each one.


GitHub Actions pipeline flow diagram for running Supabase pgTAP RLS tests

Summary: the baseline workflow gets Supabase running in CI, but running is not the same as ready, and ready is not the same as secure.

Solving the Postgres Readiness Race Condition

Why does supabase start succeed but tests still fail with connection errors? Because the CLI can return a success signal to the GitHub Actions runner before the internal Postgres Docker container has finished opening its TCP socket. Your next step runs immediately, tries to connect, and gets a connection refused error that has nothing to do with your actual test logic.

This is a documented pain point. Developers on GitHub have reported supabase start getting stuck on Waiting for health checks... and failing on timeout in CI runners, and this is exactly the kind of flaky, non-deterministic failure that erodes trust in a test suite. The reliable fix is a manual polling loop using pg_isready from the postgresql-client package, rather than trusting the CLI's own exit code.

      - name: Wait for Postgres to be ready
        run: |
          sudo apt-get install -y postgresql-client
          for i in {1..60}; do
            if pg_isready -h 127.0.0.1 -p 54322 -U postgres; then
              echo "Postgres is ready"
              exit 0
            fi
            echo "Waiting for Postgres... ($i/60)"
            sleep 2
          done
          echo "Postgres did not become ready in time"
          exit 1

This step goes right after supabase start and before anything that touches the database. It costs a few seconds on a healthy run and saves you from chasing phantom test failures on a slow one.


Diagram comparing GitHub Actions CI failure without a Postgres readiness check versus success with a pg_isready polling loop

Summary: never trust supabase start's exit code as proof the database is queryable. Poll for it explicitly.

Role Switching: Testing as authenticated, Not postgres

Simply put, testing RLS means dropping your database privileges to match what a real application connection would have, then running your assertions from inside that restricted context. In Postgres, that means issuing a SET ROLE command before your pgTAP checks and resetting it afterward.

Here's a pgTAP test structure that actually exercises RLS instead of bypassing it:

BEGIN;
SELECT plan(2);

-- Impersonate a specific tenant user instead of running as postgres
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated"}';

-- This SELECT now goes through your RLS policy, not around it
SELECT results_eq(
  'SELECT count(*) FROM invoices WHERE tenant_id = ''22222222-2222-2222-2222-222222222222''',
  ARRAY[0::bigint],
  'User from tenant A cannot see tenant B invoices'
);

SELECT throws_ok(
  $$INSERT INTO invoices (tenant_id, amount) VALUES ('22222222-2222-2222-2222-222222222222', 500)$$,
  '42501',
  NULL,
  'Inserting into another tenant''s invoices is rejected by RLS'
);

SELECT * FROM finish();
RESET ROLE;
ROLLBACK;

Using SET LOCAL instead of a plain SET matters here. It scopes the role change to the current transaction, so it's automatically undone at ROLLBACK and can't leak into a later test in the same session.

If you drop privileges and immediately hit a 42501 permission denied error on the pgTAP functions themselves (not your table), it's because plan(), ok(), and throws_ok() require explicit execution grants that the restricted role doesn't have by default. Grant them once in your test setup:

-- Run this once, outside the test transaction, as a privileged migration step
GRANT USAGE ON SCHEMA tap TO authenticated;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tap TO authenticated;

Summary: if your pgTAP tests never issue a SET ROLE, they're testing your schema, not your security.


Diagram showing how the Postgres superuser role bypasses RLS compared to a role-switched authenticated connection enforcing it

Mocking JWT Claims for auth.uid() in Headless CI

Why does auth.uid() return NULL even after I switch roles? Because auth.uid() reads the user ID out of the JWT that PostgREST normally injects into the session as request.jwt.claims. In a pgTAP test running directly against the database, there's no HTTP request and no PostgREST in the middle, so that session variable is never set, and auth.uid() evaluates to NULL.

This produces a specific and confusing failure mode: a policy written as USING (user_id = auth.uid()) doesn't error out, it just silently denies every row, because uuid = NULL evaluates to NULL in SQL's three-valued logic, which is treated as false. Your test fails, but the error tells you nothing about JWTs, it just tells you the row wasn't found. Developers have reported the same symptom from the application side too, where auth.uid() comes back null and PostgREST falls back to the anon role instead of returning an explicit error.

The fix, shown in the role-switching example above, is setting request.jwt.claims yourself before the assertion runs:

SET LOCAL "request.jwt.claims" = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated"}';

The sub claim is what auth.uid() actually reads. Set it to a real UUID from your seed data for the tenant you're impersonating, and your RLS policies will evaluate exactly as they would for a real authenticated request, without needing PostgREST in the loop at all.

Summary: a NULL auth.uid() doesn't throw an error, it just fails your policy silently, which is exactly why it's easy to miss until a real tenant hits it in production.


Diagram explaining why auth.uid() returns null in headless pgTAP tests and how mocking JWT claims fixes it

Seeding Tenant Data Before Running RLS Assertions

Simply put, you can't test tenant isolation against an empty table. RLS assertions need at least two distinct tenants with real rows so a test can prove tenant A's session genuinely cannot read or write tenant B's data, not just that a query against an empty table returns nothing.

Run migrations and your seed script explicitly as a CI step, before the readiness loop hands off to your tests:

      - name: Reset database and apply seed data
        run: supabase db reset

supabase db reset applies every migration in order and then runs seed.sql, which is where your test tenants, test users, and baseline rows should live. Keep this seed data deliberately minimal and deterministic. Two tenants with a handful of rows each is enough to prove isolation; you don't need production-scale volume for a correctness test, only for the load-testing work covered separately.

Summary: if your seed script only creates one tenant, your RLS tests can't actually prove isolation exists.

Making Sure a Failed pgTAP Test Actually Fails the Build

Does a failed pgTAP assertion automatically fail the GitHub Actions job? Not always, and this is one of the more dangerous assumptions in Supabase CI/CD. supabase test db runs pgTAP output through pg_prove, a Perl-based TAP (Test Anything Protocol) harness. If the TAP output stream reports an anomaly that pg_prove doesn't interpret as a hard failure, for example a throws_ok assertion that catches a different error code than expected, the shell command can still exit with code 0.

A green checkmark on a false-passing test is worse than no test at all, because it actively tells your team the security boundary is fine when it isn't. Two things reduce this risk:

  1. Always assert on the specific SQLSTATE error code (like 42501) in throws_ok, rather than leaving it to match any error, so a mismatched failure mode surfaces as a test failure instead of a silent pass.
  2. Add an explicit step after your test run that checks the exit code directly, rather than relying on the workflow's default pass-through behavior.
      - name: Run pgTAP RLS tests
        run: supabase test db

      - name: Confirm test step exit code
        if: always()
        run: echo "Exit code was ${{ job.status }}"

Summary: a passing CI job and a passing test suite aren't guaranteed to be the same thing with pg_prove, so pin your assertions to specific error codes rather than generic ones.

Caching Docker Layers to Cut Pipeline Time

Simply put, a cold supabase start pulls the full set of Docker images the local stack depends on, including Postgres, GoTrue, PostgREST, Realtime, and Storage, and on a fresh GitHub Actions runner that download is the single largest source of pipeline latency. Engineers have flagged runtimes exceeding three to four minutes purely for the containers to come up, before a single test runs.

Use actions/cache to persist Docker's layer cache between runs, keyed on something that changes only when your Supabase CLI version or config does:

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-docker-${{ hashFiles('supabase/config.toml') }}
          restore-keys: |
            ${{ runner.os }}-docker-

This alone doesn't eliminate the download on the very first run after a cache miss, but on every subsequent run with an unchanged config.toml, it skips re-pulling layers that haven't changed. Combined with the readiness loop from earlier, this is what takes a flaky four-minute pipeline down to something closer to under a minute on a warm cache.

Summary: the biggest latency cost in Supabase CI isn't your tests, it's the container pull, and that's the part caching actually fixes.


Bar chart comparing Supabase GitHub Actions pipeline duration with and without Docker layer caching

Quick Answers About Supabase CI/CD Testing

What Causes RLS Tests to Pass in CI But Fail in Production?

Simply put, the CI test suite is running as a superuser or table owner, and PostgreSQL exempts those roles from row-level security enforcement by design. The query executes and returns data successfully, but it never passes through the USING or WITH CHECK clause the test is meant to validate. This matters most for teams that added pgTAP tests without explicitly checking which database role the CI connection uses.

Supabase CI/CD RLS Testing at a Glance

AspectDetails
SymptomRLS tests pass in CI, tenant data leak still happens in production
Root CauseTests execute as the postgres superuser, which bypasses RLS entirely
FixSET LOCAL ROLE authenticated plus a mocked request.jwt.claims session variable
Common Side Effect42501 permission denied on pgTAP functions until GRANT EXECUTE is applied to the restricted role
Applies ToAny Supabase project using pgTAP with supabase test db in GitHub Actions

When Does This Apply?

This applies to any B2B SaaS team running pgTAP against Supabase in CI, especially once you have more than one tenant in production. If your CI pipeline only runs application-level tests (Jest, Vitest) against a mocked database layer and never runs native SQL against a real Postgres instance, these specific fixes don't apply, but you're also not actually testing your RLS policies at all.

Pros and Cons of Testing RLS Directly in pgTAP vs Application-Level Mocks

  • Pro: pgTAP tests exercise the actual policy SQL running against a real Postgres engine, not a simulated permission check.
  • Pro: Catches regressions introduced by migrations before they reach a staging branch.
  • Con: Requires the role-switching and JWT-mocking setup covered in this post; it's not automatic.
  • Con: Slower per-run than pure unit tests against a mocked ORM layer, though Docker layer caching narrows the gap significantly.

For teams building on Supabase's own CI documentation, treat that page as the baseline, not the finish line. It covers the workflow skeleton but not the role-switching, JWT-mocking, or caching work that determines whether your tests are actually meaningful.

Frequently Asked Questions

Why does supabase test db pass even when my RLS policy is broken?

Almost always because the test connection runs as the postgres superuser, which PostgreSQL exempts from RLS enforcement. Add a SET LOCAL ROLE authenticated step before your assertions to test against the actual policy.

How do I mock a JWT for pgTAP tests without deploying to Supabase?

Set the request.jwt.claims session variable directly with SET LOCAL before your assertion, containing a sub claim matching a real user ID from your seed data. This is what auth.uid() reads, with no PostgREST request required.

What does 42501 permission denied mean when running pgTAP under a restricted role?

It usually means the pgTAP functions (plan, ok, throws_ok) haven't been granted execute permission to your restricted role. Run a one-time GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tap TO authenticated as part of your migration setup.

Why does supabase start hang or fail with "service not healthy" in GitHub Actions?

The CLI can report success before the Postgres container's TCP socket has finished initializing. Add a manual pg_isready polling loop after supabase start rather than trusting the CLI's own exit code.

Should I use supabase start or a plain GitHub Actions Postgres service container for CI?

Use supabase start when you need Auth, PostgREST, or Realtime behavior in the test. Use a lightweight Postgres service container when you only need raw SQL-level pgTAP checks and want faster, simpler CI runs without the full stack.

Does ROLLBACK reliably clean up state between pgTAP test runs in CI?

Mostly, but not always. If a test triggers a fatal error mid-transaction, the ROLLBACK may never execute, leaving an aborted transaction that pollutes the next step. Wrapping the role switch in SET LOCAL, scoped to the transaction, reduces this risk.

Can I test RLS against a hosted Supabase branch instead of the local CLI stack?

Yes. Supabase Branching lets you obtain a preview database URL and run supabase test db --db-url $BRANCH_URL against real cloud infrastructure, which is useful for validating migrations before merging to production.

Why do my GitHub Actions logs show Node.js 20 deprecation warnings during Supabase CI?

This usually comes from pinning an older minor version of supabase/setup-cli@v1. Update to the latest release of the action to clear these warnings from your build logs.

Wrapping Up

A green CI checkmark on your Supabase pipeline only means something once you know what role ran the test. Get the role switching and JWT mocking right, add the readiness loop so your pipeline stops failing on timing instead of logic, and cache the Docker layers so the whole thing runs fast enough that nobody's tempted to skip it. This closes the loop that started with writing the pgTAP tests themselves: writing the assertions is half the job, running them somewhere that actually enforces RLS is the other half.

If you're still seeing slow queries even after your RLS tests are passing for the right reasons, that's a separate problem worth checking against the RLS query optimization guide. Bookmark this one for when you're setting up your next Supabase project's pipeline from scratch, it's easy to forget the role-switching step is even necessary until a test quietly lies to you.