{"version":"1.0","encoding":"UTF-8","feed":{"xmlns":"http://www.w3.org/2005/Atom","xmlns$openSearch":"http://a9.com/-/spec/opensearchrss/1.0/","xmlns$blogger":"http://schemas.google.com/blogger/2008","xmlns$georss":"http://www.georss.org/georss","xmlns$gd":"http://schemas.google.com/g/2005","xmlns$thr":"http://purl.org/syndication/thread/1.0","id":{"$t":"tag:blogger.com,1999:blog-1660802944707181277"},"updated":{"$t":"2026-09-16T10:31:31.383+05:00"},"category":[{"term":"Performance"},{"term":"Security"},{"term":"Architecture"},{"term":"Authentication"},{"term":"Testing \u0026 Ops"}],"title":{"type":"text","$t":"Rowistan | Supabase RLS \u0026amp; Multi-Tenant SaaS Architecture"},"subtitle":{"type":"html","$t":"Rowistan — where every row knows its place. Your query just went from 12ms to 1.8 seconds and you don't know why. This is where B2B SaaS engineers come to fix Supabase RLS performance, multi-tenant architecture, and security problems the official docs skip."},"link":[{"rel":"http://schemas.google.com/g/2005#feed","type":"application/atom+xml","href":"https:\/\/blog.rowistan.com\/feeds\/posts\/default"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default?alt=json\u0026max-results=3"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.rowistan.com\/"},{"rel":"hub","href":"http://pubsubhubbub.appspot.com/"},{"rel":"next","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default?alt=json\u0026start-index=4\u0026max-results=3"}],"author":[{"name":{"$t":"Mubeen Ahmad"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/10002112912394235618"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"32","src":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjJWcdkU3PHEtcRfSiZ-5rRkFL-ncfv3kSs5TudUdK_ODzC84jxOgFloVde7OLlqS62bS_taAX4V_kctnNnQcNn4MeZPl_63w3IaAwwD-re8JN2N_6QYw_UbM7DV6Y8jj1pPOWmLNBNjGE9_a5sq7aPZhGrypPJ3PhbUJB6uS7Iph4weUM\/s1600\/Rowistan-logo.png"}}],"generator":{"version":"7.00","uri":"http://www.blogger.com","$t":"Blogger"},"openSearch$totalResults":{"$t":"14"},"openSearch$startIndex":{"$t":"1"},"openSearch$itemsPerPage":{"$t":"3"},"entry":[{"id":{"$t":"tag:blogger.com,1999:blog-1660802944707181277.post-600092012504888476"},"published":{"$t":"2026-09-16T10:19:54.129+05:00"},"updated":{"$t":"2026-09-16T10:19:54.134+05:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"Security"}],"title":{"type":"text","$t":"pgTAP Tutorial: Testing RLS Policies the Right Way"},"content":{"type":"html","$t":"\u003Cimg alt=\"pgTAP RLS testing for Postgres row-level security policies\" height=\"450\" loading=\"eager\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhdY6I15ePe1UOg-qCl7EpAXyA7d1GkJSrcf-PtJTrY0RGbtSFmZ-J4MwM6ZuDwtAWpKuy25iSjTFXz-VCpDyX43Sz-gG_Vw48516vhMgalekYzHMgLq91LrUm-BdtY0zBj11TYMOanzKk25o_itUG1B9wJNKlCX2Yp7QdJseiKemUqEuW-W2DqXAvFtls\/s800\/pgTAP-RLS-Testing-for-Postgres-and-Supabase.webp\" title=\"pgTAP RLS Testing for Postgres and Supabase\" width=\"800\" \/\u003E\n\u003Cbr \/\u003E\n\u003Cp\u003EYour pgTAP RLS testing suite is green. Every assertion passes, your CI pipeline shows a clean checkmark, and you ship the migration with confidence. Then a support ticket comes in: a tenant is asking why they can see another company's invoices.\u003C\/p\u003E\n\n\u003Cp\u003EThis happens more often than most pgTAP tutorials for RLS testing let on. The typical guide shows you how to write \u003Ccode\u003Eis_empty()\u003C\/code\u003E and \u003Ccode\u003Eresults_eq()\u003C\/code\u003E assertions, then stops there. What it doesn't tell you is that the exact same test suite proving your row-level security policies work can pass while the policy underneath is wide open, or while a routine migration quietly drops the one setting that makes RLS apply to your own database owner.\u003C\/p\u003E\n\n\u003Cp\u003EThis guide covers what most pgTAP RLS testing tutorials skip: how PostgreSQL actually evaluates row-level security under the hood, why your default test role can silently defeat the entire point of testing, and five specific failure patterns that let a broken policy sail through a green test suite. If you're building a multi-tenant B2B SaaS product and want your tests to actually prove something, keep reading. And if your schema isn't locked down yet, start with \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-multi-tenant-schema.html\" title=\"The Complete Guide to Multi-Tenant Database Design in Supabase\"\u003Eour complete guide to multi-tenant Supabase architecture\u003C\/a\u003E before layering tests on top of it.\u003C\/p\u003E\n\n\u003Cdiv style=\"background: rgb(240, 247, 255); border-left: 4px solid rgb(26, 115, 232); border-radius: 4px; margin: 16px 0px; padding: 12px 16px;\"\u003E\n\u003Cstrong\u003EQuick Answer:\u003C\/strong\u003E pgTAP tests PostgreSQL row-level security by running real SQL assertions like \u003Ccode\u003Eis_empty()\u003C\/code\u003E, \u003Ccode\u003Eisnt_empty()\u003C\/code\u003E, and \u003Ccode\u003Ethrows_ok()\u003C\/code\u003E inside a transaction you roll back, so nothing you insert survives the test. The catch: PostgreSQL skips RLS entirely for the table owner and any role with BYPASSRLS unless you run \u003Ccode\u003EALTER TABLE ... FORCE ROW LEVEL SECURITY\u003C\/code\u003E, which is why a green test suite can still leak data once code runs under a privileged role in production.\n\u003C\/div\u003E\n\n\u003Ch2\u003ETable of Contents\u003C\/h2\u003E\n\u003Col\u003E\n  \u003Cli\u003E\u003Ca href=\"#what-is-pgtap-rls-testing\"\u003EWhat Is pgTAP and Why Does RLS Need Database-Level Tests?\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#default-role-bypass\"\u003EWhy Does My pgTAP Test Pass When My RLS Policy Is Broken?\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#setup-pgtap-supabase\"\u003EGetting Started with pgTAP RLS Testing in Supabase\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#writing-first-rls-test\"\u003EWriting Your First RLS Test: A Complete Walkthrough\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#tautology-trap\"\u003EWhy Does USING (true) Still Pass My pgTAP Tests?\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#force-row-level-security\"\u003EThe FORCE ROW LEVEL SECURITY Trap That Table Owners Create\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#permissive-policy-leak\"\u003EThe Silent UPDATE and DELETE Leak Your SELECT Tests Miss\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#public-role-to-clause\"\u003EWhy the TO Clause Matters: The PUBLIC Pseudo-Role Trap\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#column-level-security\"\u003EColumn-Level Security: What Row-Level Security Doesn't Protect\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#reverse-predicate-seeding\"\u003EAvoiding Decorative Tests: Seeding Data That Actually Proves Isolation\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#quick-answers\"\u003EQuick Answers About pgTAP RLS Testing\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#faq\"\u003EFrequently Asked Questions\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ol\u003E\n\n\u003Ch2 id=\"what-is-pgtap-rls-testing\"\u003EWhat Is pgTAP and Why Does RLS Need Database-Level Tests?\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003ESimply put, \u003Ca href=\"https:\/\/pgtap.org\/\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"pgTAP: Unit Testing for PostgreSQL\"\u003EpgTAP\u003C\/a\u003E is a unit testing framework that runs entirely inside PostgreSQL\u003C\/strong\u003E, using SQL functions like \u003Ccode\u003Eok()\u003C\/code\u003E, \u003Ccode\u003Eis()\u003C\/code\u003E, and \u003Ccode\u003Eresults_eq()\u003C\/code\u003E to assert facts about your database, from schema structure to runtime query results. Instead of testing RLS through your application layer (an API call, a Supabase client SDK, a middleware chain), pgTAP lets you query the database directly as different Postgres roles and check exactly what each one can see.\u003C\/p\u003E\n\n\u003Cp\u003EThat distinction matters more than it sounds. Application-level testing can only tell you what your API returns, which conflates your RLS policy with your ORM, your caching layer, and everything in between. A pgTAP test isolates the one variable that actually enforces tenant isolation: the policy itself. If a policy is broken, you find out at the database layer, not three services downstream after a customer already noticed.\u003C\/p\u003E\n\n\u003Cp\u003EThe trade-off is that pgTAP tests only prove what you explicitly assert. A test suite that never switches roles, never checks row counts, and never queries the Postgres catalog directly can still report 100% green while your isolation model is fundamentally broken. The rest of this guide walks through exactly how that happens, and how to write assertions that actually close the gap.\u003C\/p\u003E\n\n\u003Ch2 id=\"default-role-bypass\"\u003EWhy Does My pgTAP Test Pass When My RLS Policy Is Broken?\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EBecause pgTAP runs as the \u003Ccode\u003Epostgres\u003C\/code\u003E superuser by default\u003C\/strong\u003E, and PostgreSQL never enforces row-level security against a role with the BYPASSRLS attribute or against a table's owner. If your test file never explicitly switches to a lower-privileged role, every query in it sees every row in the table, whether your policy is airtight or nonexistent.\u003C\/p\u003E\n\n\u003Cp\u003EThis is the single most common source of false confidence in RLS testing, and it's also the least discussed. Developers land on the official pgTAP or Supabase documentation, copy a basic assertion pattern, and never notice their test is running with superuser privileges the entire time.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- This \"test\" will pass whether RLS works, is misconfigured, or is\n-- disabled outright, because it never leaves the postgres role\nbegin;\nselect plan(1);\n\ninsert into documents (id, tenant_id, title) values (99, 'tenant_a', 'test doc');\n\nselect isnt_empty(\n  $$ select * from documents where tenant_id = 'tenant_a' $$,\n  'a document is visible' -- true for ANY role run as postgres; meaningless as an RLS test\n);\n\nselect * from finish();\nrollback;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThe fix is procedural, not clever: every RLS assertion has to run after an explicit role switch, using \u003Ccode\u003Eset local role authenticated\u003C\/code\u003E (or your equivalent non-superuser role) and a mocked identity. If a test file skips that step, it isn't testing RLS at all, no matter how many assertions it contains.\u003C\/p\u003E\n\n\u003Ch2 id=\"setup-pgtap-supabase\"\u003EGetting Started with pgTAP RLS Testing in Supabase\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003ESetting up pgTAP RLS testing takes three steps:\u003C\/strong\u003E enable the extension, decide how you'll mock an authenticated user, and wrap every test file in a transaction you roll back so nothing you insert during testing persists.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Enable pgTAP once per database\ncreate extension if not exists pgtap;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThe part that trips people up is mocking \u003Ccode\u003Eauth.uid()\u003C\/code\u003E so a query behaves as if a specific tenant user is logged in. There are two common approaches, and the guides currently ranking for this topic are oddly split on which one to recommend.\u003C\/p\u003E\n\n\u003Cdiv class=\"rw-table-wrap\"\u003E\n\u003Ctable class=\"rw-table\"\u003E\n  \u003Cthead\u003E\n    \u003Ctr\u003E\u003Cth\u003EApproach\u003C\/th\u003E\u003Cth\u003EHow It Works\u003C\/th\u003E\u003Cth\u003ETrade-off\u003C\/th\u003E\u003C\/tr\u003E\n  \u003C\/thead\u003E\n  \u003Ctbody\u003E\n    \u003Ctr\u003E\n      \u003Ctd data-label=\"Approach\"\u003EManual JWT injection\u003C\/td\u003E\n      \u003Ctd data-label=\"How It Works\"\u003ERun \u003Ccode\u003Eset local request.jwt.claim.sub to '\u0026lt;uuid\u0026gt;'\u003C\/code\u003E before each query block\u003C\/td\u003E\n      \u003Ctd data-label=\"Trade-off\"\u003ENo extra dependency, but verbose and easy to get wrong across a large suite\u003C\/td\u003E\n    \u003C\/tr\u003E\n    \u003Ctr\u003E\n      \u003Ctd data-label=\"Approach\"\u003ECommunity test helpers\u003C\/td\u003E\n      \u003Ctd data-label=\"How It Works\"\u003EA helper extension like \u003Ccode\u003Ebasejump-supabase_test_helpers\u003C\/code\u003E wraps the same call into one function, e.g. \u003Ccode\u003Eauthenticate_as('member')\u003C\/code\u003E\u003C\/td\u003E\n      \u003Ctd data-label=\"Trade-off\"\u003ECuts boilerplate a lot, but adds a versioned extension you now have to track\u003C\/td\u003E\n    \u003C\/tr\u003E\n  \u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003C\/div\u003E\n\n\u003Cp\u003ENeither approach is objectively better. Manual injection keeps your test suite dependency-free, which matters if you're strict about what runs in CI. Helper extensions save real time once you're writing dozens of role-switching tests. Pick one and use it consistently, since mixing both patterns across a codebase is how the default-role-bypass mistake from the last section creeps back in.\u003C\/p\u003E\n\u003Cbr \/\u003E\n\u003Cimg alt=\"pgTAP RLS testing role switching diagram\" height=\"450\" loading=\"lazy\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgEmTTcYwj0sjfeQGMonR9Ar8L6ztWBzBri_Anqu488iBG-BXierTMZnSrc-2NPsLgKW_MyS1hssYPNh5GHgMmTfedLdrF9ZNCP_S-mE9wnCn-Ru9vbaoOfGTyUfhzq0xopcXhLycZD1WAxz18huI0Dk7QYj-FDN1n_g5fabL1evc4b2vAUylRfcxa62rc\/s800\/pgTAP-RLS-Testing-Role-Switching-From-postgres-to-authenticated.webp\" title=\"pgTAP RLS Testing: Role Switching From postgres to authenticated\" width=\"800\" \/\u003E\n\u003Cbr \/\u003E\n\u003Ch2 id=\"writing-first-rls-test\"\u003EWriting Your First RLS Test: A Complete Walkthrough\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EA complete RLS test has three parts:\u003C\/strong\u003E seed data as a privileged role, switch to an authenticated role and mock the tenant identity, then assert what that role can and can't see. Here's a full example testing tenant isolation on a \u003Ccode\u003Edocuments\u003C\/code\u003E table.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003Ebegin;\nselect plan(3);\n\n-- Seed data as postgres, bypassing RLS entirely for setup\nset local role postgres;\ninsert into documents (id, owner_id, tenant_id, title)\nvalues\n  (1, 'a1111111-1111-1111-1111-111111111111', 'tenant_a', 'Tenant A doc'),\n  (2, 'b2222222-2222-2222-2222-222222222222', 'tenant_b', 'Tenant B doc');\n\n-- Switch to an authenticated user belonging to tenant_a\nset local role authenticated;\nset local request.jwt.claim.sub to 'a1111111-1111-1111-1111-111111111111';\n\nselect is_empty(\n  $$ select * from documents where tenant_id = 'tenant_b' $$,\n  'tenant_a user cannot see tenant_b documents'\n);\n\nselect isnt_empty(\n  $$ select * from documents where tenant_id = 'tenant_a' $$,\n  'tenant_a user can see their own tenant documents'\n);\n\nselect results_eq(\n  $$ select count(*)::int from documents where tenant_id = 'tenant_a' $$,\n  $$ values (1) $$,\n  'exactly one row is visible, not silently filtered to zero or leaked to all'\n);\n\nselect * from finish();\nrollback;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003ENotice the final assertion checks an exact count rather than just \"something came back.\" That distinction is the difference between a test that looks reassuring and one that actually catches a broken policy, which is the entire subject of the next section.\u003C\/p\u003E\n\n\u003Cp\u003EOne detail worth flagging: because the whole file runs inside a single \u003Ccode\u003Ebegin...rollback\u003C\/code\u003E block, the rows inserted by the postgres role earlier in the same transaction are immediately visible to the authenticated-role queries that follow. Nothing needs to be committed first. Cross-transaction visibility, like waiting on a trigger or a background job in a separate connection, is a different problem entirely, and pgTAP alone won't help you test it.\u003C\/p\u003E\n\n\u003Ch2 id=\"tautology-trap\"\u003EWhy Does USING (true) Still Pass My pgTAP Tests?\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EBecause a naive existence check can't tell the difference between a correctly filtered row and a completely open table.\u003C\/strong\u003E If a policy body is \u003Ccode\u003Eusing (true)\u003C\/code\u003E, whether from a typo or from real logic that got commented out during debugging and never restored, every row in the table becomes visible to every role. An assertion like \u003Ccode\u003Eisnt_empty()\u003C\/code\u003E still passes, because rows genuinely do come back. It just doesn't check whether the \u003Cem\u003Eright\u003C\/em\u003E rows came back.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003Ebegin;\nselect plan(2);\n\nset local role authenticated;\nset local request.jwt.claim.sub to 'stranger-0000-0000-0000-000000000000';\n\n-- Misleading: true whether RLS is working correctly or wide open\nselect isnt_empty(\n  $$ select * from documents $$,\n  'stranger sees something'\n);\n\n-- The check that actually catches a tautological policy\nselect results_eq(\n  $$ select count(*)::int from documents $$,\n  $$ values (0) $$,\n  'stranger sees exactly zero rows across all tenants'\n);\n\nselect * from finish();\nrollback;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis is exactly the kind of gap we cataloged in our rundown of \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-rls-anti-patterns.html\" title=\"5 Silent RLS Mistakes That Leave Your Database Exposed\"\u003Efive silent RLS mistakes that leave your database exposed\u003C\/a\u003E. The rule that follows is simple: every negative RLS test needs an exact \u003Ccode\u003Ecount(*)\u003C\/code\u003E assertion, not just an existence check, or a wide-open policy can hide behind a passing test suite indefinitely.\u003C\/p\u003E\n\n\u003Ch2 id=\"force-row-level-security\"\u003EThe FORCE ROW LEVEL SECURITY Trap That Table Owners Create\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EPostgreSQL does not apply row-level security to a table's owner, or to any role with the SUPERUSER or BYPASSRLS attribute, unless you explicitly run \u003Ccode\u003EALTER TABLE tablename FORCE ROW LEVEL SECURITY\u003C\/code\u003E.\u003C\/strong\u003E This is documented behavior in \u003Ca href=\"https:\/\/www.postgresql.org\/docs\/current\/ddl-rowsecurity.html\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"PostgreSQL Documentation: Row Security Policies\"\u003EPostgreSQL's own row security documentation\u003C\/a\u003E, and it's the single most consequential gap across the pgTAP tutorials currently ranking for this topic. None of them mention it.\u003C\/p\u003E\n\n\u003Cp\u003EHere's why it matters in production even when your pgTAP suite is spotless. Migrations, background jobs, and connection poolers frequently connect as the database owner or a service account with elevated privileges, not as an ordinary authenticated user. If a schema refactor recreates a table and the FORCE ROW LEVEL SECURITY flag doesn't get reapplied, every policy on that table is still technically \"enabled,\" but silently inert for any privileged connection. Your pgTAP suite, which correctly authenticates as a regular user, keeps passing. Meanwhile a backend job running as the owner reads or writes across every tenant without a single check firing.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Assert the flag directly against the Postgres system catalog,\n-- rather than assuming it survived the last migration\nselect ok(\n  (select relforcerowsecurity from pg_class where relname = 'documents'),\n  'documents table has FORCE ROW LEVEL SECURITY enabled'\n);\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EAdd this single assertion to every table's test file and you've closed a gap that, as far as we could find, isn't addressed anywhere else currently ranking for pgTAP RLS testing.\u003C\/p\u003E\n\n\u003Ch2 id=\"permissive-policy-leak\"\u003EThe Silent UPDATE and DELETE Leak Your SELECT Tests Miss\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EPostgreSQL combines multiple permissive policies on the same table with OR logic, per command.\u003C\/strong\u003E That means a correctly scoped SELECT policy and an overly permissive UPDATE policy can coexist on the same table without either one being individually wrong on paper, while the table as a whole is fully exposed to unauthorized writes.\u003C\/p\u003E\n\n\u003Cp\u003EPicture a table where the SELECT policy correctly limits a user to their own tenant's rows, but the UPDATE policy uses a tautology like \u003Ccode\u003Eusing (true)\u003C\/code\u003E, maybe left over from an early prototype. A targeted pgTAP test that reads a row, updates it, and reads it back only ever touches rows the SELECT policy already allows, so it passes. A blind bulk update with no WHERE clause bypasses that visibility scoping completely and rewrites every row in the table.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003Ebegin;\nselect plan(1);\n\nset local role postgres;\ninsert into documents (id, tenant_id, title) values\n  (1, 'tenant_a', 'original'),\n  (2, 'tenant_b', 'original');\n\nset local role authenticated;\nset local request.jwt.claim.sub to 'tenant-a-user-0000-0000-000000000000';\n\n-- A blind write with no tenant scoping at all\nupdate documents set title = 'overwritten';\n\nselect results_eq(\n  $$ select title from documents where tenant_id = 'tenant_b' order by id $$,\n  $$ values ('original') $$,\n  'a tenant_a user cannot modify tenant_b rows with a blind update'\n);\n\nselect * from finish();\nrollback;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cbr \/\u003E\n\u003Cimg alt=\"permissive RLS policy OR logic diagram\" height=\"450\" loading=\"lazy\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhlBIhJ3oNJfwLSFc-j_nitUTgesrqmp2KMOkwRmtnKTeVL0M9bwEQ97LT9PXriKVgDaVuyZLZm9GRa2Mo8eTjnq8MLmJces5EZMSIOGDHHBrI-KW2JjODV7e8_spvpI2SK46ZBQwkJeMw6CwBXQ_gfShHYn0oOXmbygmR3IdeWqiXkYLonkH1AWKBlyU4\/s800\/How-Permissive-RLS%20Policies-Combine-With-OR-Logic-in-Postgres.webp\" title=\"How Permissive RLS Policies Combine With OR Logic in Postgres\" width=\"800\" \/\u003E\n\u003Cbr \/\u003E\n\u003Cp\u003ERun at least one blind, unscoped write test per table alongside your targeted ones. If it only modifies the rows you expect, your policies are consistent across commands, not just individually well-intentioned.\u003C\/p\u003E\n\n\u003Ch2 id=\"public-role-to-clause\"\u003EWhy the TO Clause Matters: The PUBLIC Pseudo-Role Trap\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EIf a policy definition omits an explicit TO clause, PostgreSQL applies it to the PUBLIC pseudo-role by default\u003C\/strong\u003E, which includes anonymous requests and any newly created role that hasn't been scoped otherwise. A policy meant only for logged-in users can end up silently covering unauthenticated ones too, simply because nobody typed \u003Ccode\u003Eto authenticated\u003C\/code\u003E when the policy was written.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003Eselect policy_roles_are(\n  'public', 'documents', 'tenant_isolation', ARRAY['authenticated'],\n  'tenant_isolation policy is scoped to authenticated only, not PUBLIC'\n);\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis assertion checks the policy's actual role scope against the catalog rather than trusting the SQL file that created it. Run it for every policy that's supposed to require authentication. It's a one-line check that catches a mistake most teams only discover during a security review.\u003C\/p\u003E\n\n\u003Ch2 id=\"column-level-security\"\u003EColumn-Level Security: What Row-Level Security Doesn't Protect\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003ERLS controls which rows a role can access, not which columns within a row it can modify.\u003C\/strong\u003E A user who passes every row-level check imaginable can still rewrite any column on their own row, including sensitive fields like a balance, an internal status flag, or a permissions field, unless you separately restrict those columns.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Restrict which columns an authenticated user can write to,\n-- independent of whatever the RLS policy allows at the row level\nrevoke update (ssn, internal_notes) on user_profiles from authenticated;\n\nbegin;\nselect plan(1);\n\nset local role authenticated;\nset local request.jwt.claim.sub to 'tenant-a-user-0000-0000-000000000000';\n\nselect throws_ok(\n  $$ update user_profiles set ssn = '000-00-0000' where id = 'tenant-a-user-0000-0000-000000000000' $$,\n  '42501',\n  null,\n  'authenticated user cannot write to ssn even on their own row'\n);\n\nselect * from finish();\nrollback;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003ENone of the pgTAP tutorials we reviewed test this. RLS and column privileges answer different questions, and a table can pass every row-level test you write while still exposing a column it should never have.\u003C\/p\u003E\n\n\u003Ch2 id=\"reverse-predicate-seeding\"\u003EAvoiding Decorative Tests: Seeding Data That Actually Proves Isolation\u003C\/h2\u003E\n\n\u003Cp\u003E\u003Cstrong\u003EA test that manually invents \"tenant A\" and \"tenant B\" data only proves the policy works against the data you imagined, not against the policy as it's actually defined.\u003C\/strong\u003E If seed data doesn't genuinely map to a different tenant under the policy's real logic, a pgTAP test can pass while the underlying policy is fundamentally broken. Community discussions on generating pgTAP tests directly from policy definitions have called this pattern out as tests that degrade into \u003Ca href=\"https:\/\/github.com\/orgs\/supabase\/discussions\/47191\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"RLS policies, proven: auto-generated pgTAP tests and auto seeded data\"\u003Edecoration rather than proof\u003C\/a\u003E.\u003C\/p\u003E\n\n\u003Cp\u003EThe more rigorous approach works backward from the policy itself, rather than forward from an assumption about what it checks.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Read the actual USING expression from the catalog instead of\n-- trusting your memory of what the policy is supposed to do\nselect polname, pg_get_expr(polqual, polrelid) as using_expression\nfrom pg_policy\nwhere polrelid = 'documents'::regclass;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EUse that output to design seed data that exactly satisfies the predicate on one side and exactly violates it on the other. For a simple \u003Ccode\u003Etenant_id = current_tenant()\u003C\/code\u003E policy this is trivial. For a compound policy combining ownership, team membership, and a published flag, it's the only way to be sure your test data exercises every branch of the condition instead of accidentally testing the same branch three times.\u003C\/p\u003E\n\n\u003Ch2 id=\"quick-answers\"\u003EQuick Answers About pgTAP RLS Testing\u003C\/h2\u003E\n\n\u003Ch3\u003EWhat Causes RLS Tests to Pass While the Policy Stays Broken?\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E a pgTAP test passes falsely when it never leaves a privileged role, checks for \"any row\" instead of an exact count, or never queries the Postgres catalog to confirm settings like FORCE ROW LEVEL SECURITY and a policy's TO clause actually survived the last migration. Each gap lets a broken policy produce a green checkmark, and it matters most on tables where more than one tenant shares storage.\u003C\/p\u003E\n\n\u003Ch3\u003EpgTAP RLS Testing at a Glance\u003C\/h3\u003E\n\u003Cdiv class=\"rw-table-wrap\"\u003E\n\u003Ctable class=\"rw-table\"\u003E\n  \u003Cthead\u003E\n    \u003Ctr\u003E\u003Cth\u003EAspect\u003C\/th\u003E\u003Cth\u003EDetails\u003C\/th\u003E\u003C\/tr\u003E\n  \u003C\/thead\u003E\n  \u003Ctbody\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ETest should run as\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EAn explicitly mocked authenticated role, never postgres or a BYPASSRLS role\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EMost common false pass\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EExistence checks (isnt_empty) instead of exact count(*) checks\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ETable owner bypass fix\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EALTER TABLE ... FORCE ROW LEVEL SECURITY, asserted against pg_class\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ECross-command leak\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003ETest a blind UPDATE\/DELETE with no WHERE clause, not just scoped reads\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EApplies to\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EAny PostgreSQL database using RLS, tested via pgTAP in Supabase or self-managed Postgres\u003C\/td\u003E\u003C\/tr\u003E\n  \u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003C\/div\u003E\n\n\u003Ch3\u003EWhen Does This Level of RLS Testing Matter?\u003C\/h3\u003E\n\u003Cp\u003EIt matters most for multi-tenant B2B SaaS products storing more than one customer's rows in shared tables, where RLS is the primary isolation boundary. It also matters for teams facing SOC 2 or GDPR-style compliance audits, where auditors increasingly expect proof that access controls are tested, not just configured.\u003C\/p\u003E\n\n\u003Ch2 id=\"faq\"\u003EFrequently Asked Questions\u003C\/h2\u003E\n\n\u003Ch3\u003EWhat is pgTAP used for?\u003C\/h3\u003E\n\u003Cp\u003EpgTAP is a unit testing framework for PostgreSQL that runs as SQL functions inside the database itself. It lets you assert facts about your schema, like table or column existence, and about runtime behavior, like whether a row-level security policy actually filters the data it's supposed to.\u003C\/p\u003E\n\n\u003Ch3\u003EHow do I test RLS policies in Supabase?\u003C\/h3\u003E\n\u003Cp\u003EInstall the pgTAP extension, wrap assertions in a transaction you roll back, seed data as the postgres role to bypass RLS, then switch to the authenticated role and mock a user identity before running \u003Ccode\u003Eis_empty()\u003C\/code\u003E, \u003Ccode\u003Eisnt_empty()\u003C\/code\u003E, or \u003Ccode\u003Eresults_eq()\u003C\/code\u003E checks against the policy you're testing.\u003C\/p\u003E\n\n\u003Ch3\u003EWhy does my pgTAP test pass as the postgres role even when RLS is broken?\u003C\/h3\u003E\n\u003Cp\u003EThe postgres role is a superuser with the BYPASSRLS attribute, so PostgreSQL skips row-level security for it entirely. Any test that never switches to an authenticated role is testing your schema, not your policy, and will pass regardless of what that policy actually does.\u003C\/p\u003E\n\n\u003Ch3\u003EDo I need basejump-supabase_test_helpers to test RLS with pgTAP?\u003C\/h3\u003E\n\u003Cp\u003ENo. You can mock a user manually by setting a JWT claim yourself, but community helper extensions wrap that boilerplate into a single function call and are easier to maintain across a large suite. Either approach works as long as you actually switch roles first.\u003C\/p\u003E\n\n\u003Ch3\u003EDoes an insert need to be committed before I can test it with pgTAP?\u003C\/h3\u003E\n\u003Cp\u003ENo. pgTAP wraps an entire test file in one uncommitted transaction, so inserts made earlier in that same transaction are visible to later statements in the same file, even though nothing is written to disk. Cross-transaction visibility is a separate, unrelated problem.\u003C\/p\u003E\n\n\u003Ch3\u003ECan a policy with USING (true) really pass an RLS test?\u003C\/h3\u003E\n\u003Cp\u003EYes, if your test only checks that a row comes back. An existence check returns true whether a policy correctly filtered one row or leaked every row in the table. Use a \u003Ccode\u003Ecount(*)\u003C\/code\u003E assertion with an exact expected value to catch a tautological policy that leaks everything.\u003C\/p\u003E\n\n\u003Ch3\u003EDoes row-level security protect specific columns?\u003C\/h3\u003E\n\u003Cp\u003ENo. RLS controls which rows a role can see or write, not which columns within them. A user who passes every row-level check can still update a column like a balance or an internal flag on their own row, unless you separately revoke column privileges.\u003C\/p\u003E\n\n\u003Ch3\u003EHow do I run pgTAP tests automatically in CI\/CD?\u003C\/h3\u003E\n\u003Cp\u003EThe Supabase CLI's \u003Ccode\u003Etest db\u003C\/code\u003E command runs your pgTAP suite against a local database and can be wired into a GitHub Actions workflow on every pull request. We're covering the full pipeline setup in a dedicated post soon.\u003C\/p\u003E\n\n\u003C!--internal link pending - no live post yet for \"Automating RLS Tests in GitHub Actions (CI\/CD)\" (Article #23)--\u003E\n\n\u003Ch2\u003EFinal Thoughts\u003C\/h2\u003E\n\n\u003Cp\u003EpgTAP RLS testing only proves what you actually asked it to check. A green suite that never leaves the postgres role, never counts rows, and never asserts FORCE ROW LEVEL SECURITY against the catalog isn't proof your multi-tenant schema is safe. It's decoration. Add explicit role switching, exact \u003Ccode\u003Ecount(*)\u003C\/code\u003E assertions, and a couple of catalog-level checks to your existing suite, and \"the tests pass\" starts meaning something you can defend in a security review or a SOC 2 audit.\u003C\/p\u003E\n\n\u003Cp\u003EIf your policies are already slow enough that engineers are tempted to loosen them just to make queries run faster, that's worth resolving before you write another test. \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/09\/debug-postgres-rls-query-plan.html\" title=\"Reading EXPLAIN ANALYZE to Debug Postgres RLS Query Plans\"\u003EReading EXPLAIN ANALYZE output\u003C\/a\u003E will tell you whether a slow policy is a testing problem or a query planner problem before you touch a single line of SQL. Once these tests exist, the next step is making sure they run correctly on every pull request, not just on your laptop. See \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/09\/supabase-cicd-pgtap-github-actions.html\" title=\"Automating pgTAP RLS Tests in GitHub Actions (CI\/CD)\"\u003Eautomating pgTAP RLS tests in GitHub Actions\u003C\/a\u003E for the CI setup, including the role-switching step that most tutorials skip. Bookmark this guide, and run the FORCE ROW LEVEL SECURITY check against your own schema today. It takes thirty seconds and it's the one gap none of the existing pgTAP tutorials mention.\u003C\/p\u003E\n\n\u003Cscript type=\"application\/ld+json\"\u003E\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What is pgTAP used for?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"pgTAP is a unit testing framework for PostgreSQL that runs as SQL functions inside the database itself. It lets you assert facts about your schema, like table or column existence, and about runtime behavior, like whether a row-level security policy actually filters the data it is supposed to.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I test RLS policies in Supabase?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Install the pgTAP extension, wrap assertions in a transaction you roll back, seed data as the postgres role to bypass RLS, then switch to the authenticated role and mock a user identity before running is_empty, isnt_empty, or results_eq checks against the policy you are testing.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does my pgTAP test pass as the postgres role even when RLS is broken?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"The postgres role is a superuser with the BYPASSRLS attribute, so PostgreSQL skips row-level security for it entirely. Any test that never switches to an authenticated role is testing your schema, not your policy, and will pass regardless of what that policy actually does.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Do I need basejump-supabase_test_helpers to test RLS with pgTAP?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"No. You can mock a user manually by setting a JWT claim yourself, but community helper extensions wrap that boilerplate into a single function call and are easier to maintain across a large suite. Either approach works as long as you actually switch roles first.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Does an insert need to be committed before I can test it with pgTAP?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"No. pgTAP wraps an entire test file in one uncommitted transaction, so inserts made earlier in that same transaction are visible to later statements in the same file, even though nothing is written to disk. Cross-transaction visibility is a separate, unrelated problem.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can a policy with USING (true) really pass an RLS test?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes, if your test only checks that a row comes back. An existence check returns true whether a policy correctly filtered one row or leaked every row in the table. Use a count assertion with an exact expected value to catch a tautological policy that leaks everything.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Does row-level security protect specific columns?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"No. RLS controls which rows a role can see or write, not which columns within them. A user who passes every row-level check can still update a column like a balance or an internal flag on their own row, unless you separately revoke column privileges.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I run pgTAP tests automatically in CI\/CD?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"The Supabase CLI's test db command runs your pgTAP suite against a local database and can be wired into a GitHub Actions workflow on every pull request.\"\n      }\n    }\n  ]\n}\n\u003C\/script\u003E"},"link":[{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/600092012504888476"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/600092012504888476"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.rowistan.com\/2026\/09\/pgtap-rls-testing-tutorial.html","title":"pgTAP Tutorial: Testing RLS Policies the Right Way"}],"author":[{"name":{"$t":"Mubeen Ahmad"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/10002112912394235618"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"32","src":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjJWcdkU3PHEtcRfSiZ-5rRkFL-ncfv3kSs5TudUdK_ODzC84jxOgFloVde7OLlqS62bS_taAX4V_kctnNnQcNn4MeZPl_63w3IaAwwD-re8JN2N_6QYw_UbM7DV6Y8jj1pPOWmLNBNjGE9_a5sq7aPZhGrypPJ3PhbUJB6uS7Iph4weUM\/s1600\/Rowistan-logo.png"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhdY6I15ePe1UOg-qCl7EpAXyA7d1GkJSrcf-PtJTrY0RGbtSFmZ-J4MwM6ZuDwtAWpKuy25iSjTFXz-VCpDyX43Sz-gG_Vw48516vhMgalekYzHMgLq91LrUm-BdtY0zBj11TYMOanzKk25o_itUG1B9wJNKlCX2Yp7QdJseiKemUqEuW-W2DqXAvFtls\/s72-c\/pgTAP-RLS-Testing-for-Postgres-and-Supabase.webp","height":"72","width":"72"}},{"id":{"$t":"tag:blogger.com,1999:blog-1660802944707181277.post-605684042517873578"},"published":{"$t":"2026-09-16T10:15:41.762+05:00"},"updated":{"$t":"2026-09-16T10:15:41.762+05:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"Testing \u0026 Ops"}],"title":{"type":"text","$t":"Automating pgTAP RLS Tests in GitHub Actions (CI\/CD)"},"content":{"type":"html","$t":"\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjtHuQP6H6KCOme8lLmijfyBW0la4RIO8srZfMoV1Ml2G1wm9Av4eWsm93h5NVdvczcAGpWr_jgDtQbKXA5Fw_-PujE700jka_brlh2WaCkBOusAdue15bBXTdd3VWrMpQrquUjqirmFP9l8JlgZW1xi8b7AQnhpV2ErAmM_LYOxPntup8Vvtm2GM4s9Fc\/s800\/supabase-cicd-pgtap-testing-github-actions-featured.webp\" \n  alt=\"GitHub Actions pipeline running pgTAP RLS tests for a Supabase database\" \n  title=\"Automating pgTAP RLS Tests in Supabase CI\/CD\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"eager\"\n\/\u003E\n\u003Cbr\u003E\n\u003Cp\u003EYou 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.\u003C\/p\u003E\n\n\u003Cp\u003EHere's what happened: your CI pipeline ran the tests as the \u003Ccode\u003Epostgres\u003C\/code\u003E 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 \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/09\/pgtap-rls-testing-tutorial.html\" title=\"pgTAP: Testing RLS Policies Properly\"\u003Estop at the pgTAP syntax\u003C\/a\u003E and never get to the orchestration problems that show up only in a stateless runner.\u003C\/p\u003E\n\n\u003Cp\u003EThis 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 \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-multi-tenant-schema.html\" title=\"The Complete Guide to Multi-Tenant Database Design in Supabase\"\u003Ecomplete guide to multi-tenant database design in Supabase\u003C\/a\u003E first, since everything here assumes tenant isolation is already the thing you're protecting.\u003C\/p\u003E\n\n\u003Cdiv style=\"background:#f0f7ff;border-left:4px solid #1a73e8;padding:12px 16px;margin:16px 0;border-radius:4px\"\u003E\n\u003Cstrong\u003EQuick Answer:\u003C\/strong\u003E Supabase CI\/CD tests fail silently on RLS because the default connection runs as the \u003Ccode\u003Epostgres\u003C\/code\u003E superuser, which PostgreSQL exempts from row-level security by design. Fix it by running pgTAP assertions under \u003Ccode\u003ESET ROLE authenticated\u003C\/code\u003E with a mocked \u003Ccode\u003Erequest.jwt.claims\u003C\/code\u003E session variable, and add a manual \u003Ccode\u003Epg_isready\u003C\/code\u003E polling loop, since \u003Ccode\u003Esupabase start\u003C\/code\u003E can return success in GitHub Actions before the Postgres container's TCP socket is actually ready.\n\u003C\/div\u003E\n\n\u003Ch2\u003ETable of Contents\u003C\/h2\u003E\n\u003Col\u003E\n  \u003Cli\u003E\u003Ca href=\"#why-ci-tests-lie\"\u003EWhy Your CI Tests Might Be Lying to You\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#workflow-file\"\u003ESetting Up the GitHub Actions Workflow File\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#readiness-loop\"\u003ESolving the Postgres Readiness Race Condition\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#role-switching\"\u003ERole Switching: Testing as authenticated, Not postgres\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#jwt-mocking\"\u003EMocking JWT Claims for auth.uid() in Headless CI\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#seeding-tenant-data\"\u003ESeeding Tenant Data Before Running RLS Assertions\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#failing-the-build\"\u003EMaking Sure a Failed pgTAP Test Actually Fails the Build\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#docker-caching\"\u003ECaching Docker Layers to Cut Pipeline Time\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#quick-answers\"\u003EQuick Answers About Supabase CI\/CD Testing\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#faq\"\u003EFrequently Asked Questions\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ol\u003E\n\n\u003Ch2 id=\"why-ci-tests-lie\"\u003EWhy Your CI Tests Might Be Lying to You\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E 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 \u003Ccode\u003EUSING\u003C\/code\u003E or \u003Ccode\u003EWITH CHECK\u003C\/code\u003E clause you're trying to verify.\u003C\/p\u003E\n\u003Cp\u003EThis isn't a hypothetical. It's one of the most repeated complaints from engineers setting up Supabase testing: developers running pgTAP through \u003Ccode\u003Esupabase test db\u003C\/code\u003E discover their tests can't stress-test RLS policies because everything executes as the \u003Ccode\u003Epostgres\u003C\/code\u003E role by default. The workflow reports green. The security boundary is untested.\u003C\/p\u003E\n\u003Cp\u003EThe 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.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIn short:\u003C\/strong\u003E a passing RLS test suite in CI means nothing until you've confirmed it's running as a restricted role, not the database owner.\u003C\/p\u003E\n\n\u003Ch2 id=\"workflow-file\"\u003ESetting Up the GitHub Actions Workflow File\u003C\/h2\u003E\n\u003Cp\u003EStart 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.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003Ename: pgTAP RLS Tests\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\njobs:\n  rls-tests:\n    runs-on: ubuntu-latest\n    env:\n      SUPABASE_DB_URL: postgresql:\/\/postgres:postgres@127.0.0.1:54322\/postgres\n    steps:\n      - name: Checkout repository\n        uses: actions\/checkout@v4\n\n      - name: Install Supabase CLI\n        uses: supabase\/setup-cli@v1\n        with:\n          version: latest\n\n      - name: Start Supabase local stack\n        run: supabase start\n\n      # Readiness loop, role switching, and JWT mocking steps go here\n      # (covered in the sections below)\n\n      - name: Run pgTAP RLS tests\n        run: supabase test db\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EOn 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.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhHFCc-ZI55ye-T_EcbWQV-ZOHbLrwHrKd9QySMkFNepDVvg5MJc48HPbwHXscmMNXZNW7TJW7o5_RRGPTCnX96TnO7QgNVyGj8SR9v0a9HRWyNmjlV_D6ik0eb6wlPCUkMLdz-lOKWdErbDfee7vWhfGPFKrip0800FNh_ZuZOk19GG4aRaf-MlUldh0I\/s800\/supabase-github-actions-pgtap-pipeline-flow-diagram.webp\" \n  alt=\"GitHub Actions pipeline flow diagram for running Supabase pgTAP RLS tests\" \n  title=\"Supabase CI\/CD Pipeline Stages for pgTAP RLS Testing\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E the baseline workflow gets Supabase running in CI, but running is not the same as ready, and ready is not the same as secure.\u003C\/p\u003E\n\n\u003Ch2 id=\"readiness-loop\"\u003ESolving the Postgres Readiness Race Condition\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhy does \u003Ccode\u003Esupabase start\u003C\/code\u003E succeed but tests still fail with connection errors?\u003C\/strong\u003E 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.\u003C\/p\u003E\n\u003Cp\u003EThis is a documented pain point. Developers on GitHub have reported \u003Ccode\u003Esupabase start\u003C\/code\u003E getting stuck on \u003Ccode\u003EWaiting for health checks...\u003C\/code\u003E 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 \u003Ccode\u003Epg_isready\u003C\/code\u003E from the \u003Ccode\u003Epostgresql-client\u003C\/code\u003E package, rather than trusting the CLI's own exit code.\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003E      - name: Wait for Postgres to be ready\n        run: |\n          sudo apt-get install -y postgresql-client\n          for i in {1..60}; do\n            if pg_isready -h 127.0.0.1 -p 54322 -U postgres; then\n              echo \"Postgres is ready\"\n              exit 0\n            fi\n            echo \"Waiting for Postgres... ($i\/60)\"\n            sleep 2\n          done\n          echo \"Postgres did not become ready in time\"\n          exit 1\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis step goes right after \u003Ccode\u003Esupabase start\u003C\/code\u003E 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.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjEdxfaWL83YvObMFduK8Z1GAO2hUhumIq3So_0OyxZCPc65y5_Apqs5-32CsRH15cFQNBfFciV7E2sBakcyjXFBiNJBa0VudEHYf3_3NRaG-uFgY2HsrK0_tJmEX9KKhAyAB53iEun-389wyGZD1Vy-a1_wBIDvRUrXRiQl81uICssY6-wZg70sfll-Ew\/s800\/supabase-postgres-readiness-race-condition-before-after.webp\" \n  alt=\"Diagram comparing GitHub Actions CI failure without a Postgres readiness check versus success with a pg_isready polling loop\" \n  title=\"Fixing the Postgres Readiness Race Condition in Supabase CI\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E never trust \u003Ccode\u003Esupabase start\u003C\/code\u003E's exit code as proof the database is queryable. Poll for it explicitly.\u003C\/p\u003E\n\n\u003Ch2 id=\"role-switching\"\u003ERole Switching: Testing as authenticated, Not postgres\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E 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 \u003Ccode\u003ESET ROLE\u003C\/code\u003E command before your pgTAP checks and resetting it afterward.\u003C\/p\u003E\n\u003Cp\u003EHere's a pgTAP test structure that actually exercises RLS instead of bypassing it:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003EBEGIN;\nSELECT plan(2);\n\n-- Impersonate a specific tenant user instead of running as postgres\nSET LOCAL ROLE authenticated;\nSET LOCAL \"request.jwt.claims\" = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\"}';\n\n-- This SELECT now goes through your RLS policy, not around it\nSELECT results_eq(\n  'SELECT count(*) FROM invoices WHERE tenant_id = ''22222222-2222-2222-2222-222222222222''',\n  ARRAY[0::bigint],\n  'User from tenant A cannot see tenant B invoices'\n);\n\nSELECT throws_ok(\n  $$INSERT INTO invoices (tenant_id, amount) VALUES ('22222222-2222-2222-2222-222222222222', 500)$$,\n  '42501',\n  NULL,\n  'Inserting into another tenant''s invoices is rejected by RLS'\n);\n\nSELECT * FROM finish();\nRESET ROLE;\nROLLBACK;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EUsing \u003Ccode\u003ESET LOCAL\u003C\/code\u003E instead of a plain \u003Ccode\u003ESET\u003C\/code\u003E matters here. It scopes the role change to the current transaction, so it's automatically undone at \u003Ccode\u003EROLLBACK\u003C\/code\u003E and can't leak into a later test in the same session.\u003C\/p\u003E\n\u003Cp\u003EIf you drop privileges and immediately hit a \u003Ccode\u003E42501 permission denied\u003C\/code\u003E error on the pgTAP functions themselves (not your table), it's because \u003Ccode\u003Eplan()\u003C\/code\u003E, \u003Ccode\u003Eok()\u003C\/code\u003E, and \u003Ccode\u003Ethrows_ok()\u003C\/code\u003E require explicit execution grants that the restricted role doesn't have by default. Grant them once in your test setup:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Run this once, outside the test transaction, as a privileged migration step\nGRANT USAGE ON SCHEMA tap TO authenticated;\nGRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tap TO authenticated;\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E if your pgTAP tests never issue a \u003Ccode\u003ESET ROLE\u003C\/code\u003E, they're testing your schema, not your security.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhBMfIg6jJIhrIpRHY_owYFxiQIhZA89lHcDt6ygbJC4rFgg1Mc6VZmduXjZKC6DS5I-RH7EvbVsT9ne_ankZ6P2zepB1Xe4fkY1GYS-cpnd9PyAKJSbbUu51a1CFwwQf2goM3BOIRkH8P5eN2o5AzE1V0Zz4-9n2UserC2hqUIify6E4BrYv4c14DuXz4\/s800\/postgres-superuser-bypass-vs-rls-role-switching-diagram.webp\" \n  alt=\"Diagram showing how the Postgres superuser role bypasses RLS compared to a role-switched authenticated connection enforcing it\" \n  title=\"Superuser RLS Bypass vs Role Switching in pgTAP Tests\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"jwt-mocking\"\u003EMocking JWT Claims for auth.uid() in Headless CI\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhy does \u003Ccode\u003Eauth.uid()\u003C\/code\u003E return NULL even after I switch roles?\u003C\/strong\u003E Because \u003Ccode\u003Eauth.uid()\u003C\/code\u003E reads the user ID out of the JWT that PostgREST normally injects into the session as \u003Ccode\u003Erequest.jwt.claims\u003C\/code\u003E. 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 \u003Ccode\u003Eauth.uid()\u003C\/code\u003E evaluates to NULL.\u003C\/p\u003E\n\u003Cp\u003EThis produces a specific and confusing failure mode: a policy written as \u003Ccode\u003EUSING (user_id = auth.uid())\u003C\/code\u003E doesn't error out, it just silently denies every row, because \u003Ccode\u003Euuid = NULL\u003C\/code\u003E 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 \u003Ccode\u003Eauth.uid()\u003C\/code\u003E comes back null and \u003Ca href=\"https:\/\/github.com\/supabase\/supabase\/issues\/37320\" title=\"Clarify auth.uid() returns null when no session is present in RLS - GitHub Issue\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EPostgREST falls back to the anon role instead of returning an explicit error\u003C\/a\u003E.\u003C\/p\u003E\n\u003Cp\u003EThe fix, shown in the role-switching example above, is setting \u003Ccode\u003Erequest.jwt.claims\u003C\/code\u003E yourself before the assertion runs:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003ESET LOCAL \"request.jwt.claims\" = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\"}';\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThe \u003Ccode\u003Esub\u003C\/code\u003E claim is what \u003Ccode\u003Eauth.uid()\u003C\/code\u003E 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.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E a NULL \u003Ccode\u003Eauth.uid()\u003C\/code\u003E 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.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi9QiYGNiZeGbbXoQwLdZUCg_RZpccqe0a5dHc9ln2i0TrDDU1uiefwItQBwixc-CiNDdmomXNkcLFd3SXD8bCPBf9jukd9RpNeEut_GOZHTJ54rve_5X4RfInvH2EeDJSP21fRoekfZpp04jgRwL900ohA02o-V_IXCMSMnIj-25v-0y9YvF3K3m7KL4E\/s800\/supabase-auth-uid-null-jwt-mocking-pgtap-diagram.webp\" \n  alt=\"Diagram explaining why auth.uid() returns null in headless pgTAP tests and how mocking JWT claims fixes it\" \n  title=\"Mocking JWT Claims for auth.uid() in Supabase CI Tests\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"seeding-tenant-data\"\u003ESeeding Tenant Data Before Running RLS Assertions\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E 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.\u003C\/p\u003E\n\u003Cp\u003ERun migrations and your seed script explicitly as a CI step, before the readiness loop hands off to your tests:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003E      - name: Reset database and apply seed data\n        run: supabase db reset\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003E\u003Ccode\u003Esupabase db reset\u003C\/code\u003E applies every migration in order and then runs \u003Ccode\u003Eseed.sql\u003C\/code\u003E, 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.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E if your seed script only creates one tenant, your RLS tests can't actually prove isolation exists.\u003C\/p\u003E\n\n\u003Ch2 id=\"failing-the-build\"\u003EMaking Sure a Failed pgTAP Test Actually Fails the Build\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EDoes a failed pgTAP assertion automatically fail the GitHub Actions job?\u003C\/strong\u003E Not always, and this is one of the more dangerous assumptions in Supabase CI\/CD. \u003Ccode\u003Esupabase test db\u003C\/code\u003E runs pgTAP output through \u003Ccode\u003Epg_prove\u003C\/code\u003E, a Perl-based TAP (Test Anything Protocol) harness. If the TAP output stream reports an anomaly that \u003Ccode\u003Epg_prove\u003C\/code\u003E doesn't interpret as a hard failure, for example a \u003Ccode\u003Ethrows_ok\u003C\/code\u003E assertion that catches a different error code than expected, the shell command can still exit with code 0.\u003C\/p\u003E\n\u003Cp\u003EA 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:\u003C\/p\u003E\n\u003Col\u003E\n  \u003Cli\u003EAlways assert on the specific SQLSTATE error code (like \u003Ccode\u003E42501\u003C\/code\u003E) in \u003Ccode\u003Ethrows_ok\u003C\/code\u003E, rather than leaving it to match any error, so a mismatched failure mode surfaces as a test failure instead of a silent pass.\u003C\/li\u003E\n  \u003Cli\u003EAdd an explicit step after your test run that checks the exit code directly, rather than relying on the workflow's default pass-through behavior.\u003C\/li\u003E\n\u003C\/ol\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003E      - name: Run pgTAP RLS tests\n        run: supabase test db\n\n      - name: Confirm test step exit code\n        if: always()\n        run: echo \"Exit code was ${{ job.status }}\"\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E 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.\u003C\/p\u003E\n\n\u003Ch2 id=\"docker-caching\"\u003ECaching Docker Layers to Cut Pipeline Time\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E a cold \u003Ccode\u003Esupabase start\u003C\/code\u003E 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.\u003C\/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003Eactions\/cache\u003C\/code\u003E to persist Docker's layer cache between runs, keyed on something that changes only when your Supabase CLI version or config does:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003E      - name: Cache Docker layers\n        uses: actions\/cache@v4\n        with:\n          path: \/tmp\/.buildx-cache\n          key: ${{ runner.os }}-docker-${{ hashFiles('supabase\/config.toml') }}\n          restore-keys: |\n            ${{ runner.os }}-docker-\n\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis alone doesn't eliminate the download on the very first run after a cache miss, but on every subsequent run with an unchanged \u003Ccode\u003Econfig.toml\u003C\/code\u003E, 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.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESummary:\u003C\/strong\u003E the biggest latency cost in Supabase CI isn't your tests, it's the container pull, and that's the part caching actually fixes.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgenanUHjUKiCJ_E8ekeHs8CdX2hGvAxatx7MX3jav4Qsevl9X2jYm5t19jvo9ZpasXcfd1DGvJBHwtZQAX0ubY2BqkN-Icw5fkL-5mCerc_elBIddkwwbiDFTYUvRoaVq__YBFHruD5pLnrd1BYOsEVh73BeP1GxgWVj67_ev2HbewYP7ulS03WF2aq48\/s800\/supabase-docker-layer-caching-pipeline-speed-comparison.webp\" \n  alt=\"Bar chart comparing Supabase GitHub Actions pipeline duration with and without Docker layer caching\" \n  title=\"Docker Layer Caching Impact on Supabase CI Pipeline Speed\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"quick-answers\"\u003EQuick Answers About Supabase CI\/CD Testing\u003C\/h2\u003E\n\n\u003Ch3\u003EWhat Causes RLS Tests to Pass in CI But Fail in Production?\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E 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 \u003Ccode\u003EUSING\u003C\/code\u003E or \u003Ccode\u003EWITH CHECK\u003C\/code\u003E 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.\u003C\/p\u003E\n\n\u003Ch3\u003ESupabase CI\/CD RLS Testing at a Glance\u003C\/h3\u003E\n\u003Cdiv class=\"rw-table-wrap\"\u003E\n\u003Ctable class=\"rw-table\"\u003E\n  \u003Cthead\u003E\n    \u003Ctr\u003E\u003Cth\u003EAspect\u003C\/th\u003E\u003Cth\u003EDetails\u003C\/th\u003E\u003C\/tr\u003E\n  \u003C\/thead\u003E\n  \u003Ctbody\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ESymptom\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003ERLS tests pass in CI, tenant data leak still happens in production\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ERoot Cause\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003ETests execute as the postgres superuser, which bypasses RLS entirely\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EFix\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003ESET LOCAL ROLE authenticated plus a mocked request.jwt.claims session variable\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ECommon Side Effect\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003E42501 permission denied on pgTAP functions until GRANT EXECUTE is applied to the restricted role\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EApplies To\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EAny Supabase project using pgTAP with supabase test db in GitHub Actions\u003C\/td\u003E\u003C\/tr\u003E\n  \u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003C\/div\u003E\n\n\u003Ch3\u003EWhen Does This Apply?\u003C\/h3\u003E\n\u003Cp\u003EThis 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.\u003C\/p\u003E\n\n\u003Ch3\u003EPros and Cons of Testing RLS Directly in pgTAP vs Application-Level Mocks\u003C\/h3\u003E\n\u003Cul\u003E\n  \u003Cli\u003E\u003Cstrong\u003EPro:\u003C\/strong\u003E pgTAP tests exercise the actual policy SQL running against a real Postgres engine, not a simulated permission check.\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003EPro:\u003C\/strong\u003E Catches regressions introduced by migrations before they reach a staging branch.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cul\u003E\n  \u003Cli\u003E\u003Cstrong\u003ECon:\u003C\/strong\u003E Requires the role-switching and JWT-mocking setup covered in this post; it's not automatic.\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003ECon:\u003C\/strong\u003E Slower per-run than pure unit tests against a mocked ORM layer, though Docker layer caching narrows the gap significantly.\u003C\/li\u003E\n\u003C\/ul\u003E\n\n\u003Cp\u003EFor teams building on \u003Ca href=\"https:\/\/supabase.com\/docs\/guides\/deployment\/ci\/testing\" title=\"Automated testing using GitHub Actions - Supabase Docs\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ESupabase's own CI documentation\u003C\/a\u003E, 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.\u003C\/p\u003E\n\n\u003Ch2 id=\"faq\"\u003EFrequently Asked Questions\u003C\/h2\u003E\n\n\u003Ch3\u003EWhy does supabase test db pass even when my RLS policy is broken?\u003C\/h3\u003E\n\u003Cp\u003EAlmost 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.\u003C\/p\u003E\n\n\u003Ch3\u003EHow do I mock a JWT for pgTAP tests without deploying to Supabase?\u003C\/h3\u003E\n\u003Cp\u003ESet 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.\u003C\/p\u003E\n\n\u003Ch3\u003EWhat does 42501 permission denied mean when running pgTAP under a restricted role?\u003C\/h3\u003E\n\u003Cp\u003EIt 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.\u003C\/p\u003E\n\n\u003Ch3\u003EWhy does supabase start hang or fail with \"service not healthy\" in GitHub Actions?\u003C\/h3\u003E\n\u003Cp\u003EThe 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.\u003C\/p\u003E\n\n\u003Ch3\u003EShould I use supabase start or a plain GitHub Actions Postgres service container for CI?\u003C\/h3\u003E\n\u003Cp\u003EUse 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.\u003C\/p\u003E\n\n\u003Ch3\u003EDoes ROLLBACK reliably clean up state between pgTAP test runs in CI?\u003C\/h3\u003E\n\u003Cp\u003EMostly, 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.\u003C\/p\u003E\n\n\u003Ch3\u003ECan I test RLS against a hosted Supabase branch instead of the local CLI stack?\u003C\/h3\u003E\n\u003Cp\u003EYes. 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.\u003C\/p\u003E\n\n\u003Ch3\u003EWhy do my GitHub Actions logs show Node.js 20 deprecation warnings during Supabase CI?\u003C\/h3\u003E\n\u003Cp\u003EThis 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.\u003C\/p\u003E\n\n\u003Ch2\u003EWrapping Up\u003C\/h2\u003E\n\u003Cp\u003EA 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 \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/09\/pgtap-rls-testing-tutorial.html\" title=\"pgTAP: Testing RLS Policies Properly\"\u003Ewriting the pgTAP tests themselves\u003C\/a\u003E: writing the assertions is half the job, running them somewhere that actually enforces RLS is the other half.\u003C\/p\u003E\n\u003Cp\u003EIf 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 \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-rls-query-optimization.html\" title=\"RLS Query Optimization: The Fix That Cuts Query Time 90%+\"\u003ERLS query optimization guide\u003C\/a\u003E. 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.\u003C\/p\u003E\n\n\u003Cscript type=\"application\/ld+json\"\u003E\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does supabase test db pass even when my RLS policy is broken?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I mock a JWT for pgTAP tests without deploying to Supabase?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What does 42501 permission denied mean when running pgTAP under a restricted role?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does supabase start hang or fail with \\\"service not healthy\\\" in GitHub Actions?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Should I use supabase start or a plain GitHub Actions Postgres service container for CI?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Does ROLLBACK reliably clean up state between pgTAP test runs in CI?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I test RLS against a hosted Supabase branch instead of the local CLI stack?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why do my GitHub Actions logs show Node.js 20 deprecation warnings during Supabase CI?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    }\n  ]\n}\n\u003C\/script\u003E"},"link":[{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/605684042517873578"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/605684042517873578"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.rowistan.com\/2026\/09\/supabase-cicd-pgtap-github-actions.html","title":"Automating pgTAP RLS Tests in GitHub Actions (CI\/CD)"}],"author":[{"name":{"$t":"Mubeen Ahmad"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/10002112912394235618"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"32","src":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjJWcdkU3PHEtcRfSiZ-5rRkFL-ncfv3kSs5TudUdK_ODzC84jxOgFloVde7OLlqS62bS_taAX4V_kctnNnQcNn4MeZPl_63w3IaAwwD-re8JN2N_6QYw_UbM7DV6Y8jj1pPOWmLNBNjGE9_a5sq7aPZhGrypPJ3PhbUJB6uS7Iph4weUM\/s1600\/Rowistan-logo.png"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjtHuQP6H6KCOme8lLmijfyBW0la4RIO8srZfMoV1Ml2G1wm9Av4eWsm93h5NVdvczcAGpWr_jgDtQbKXA5Fw_-PujE700jka_brlh2WaCkBOusAdue15bBXTdd3VWrMpQrquUjqirmFP9l8JlgZW1xi8b7AQnhpV2ErAmM_LYOxPntup8Vvtm2GM4s9Fc\/s72-c\/supabase-cicd-pgtap-testing-github-actions-featured.webp","height":"72","width":"72"}},{"id":{"$t":"tag:blogger.com,1999:blog-1660802944707181277.post-8313036209488357074"},"published":{"$t":"2026-09-11T00:04:17.975+05:00"},"updated":{"$t":"2026-09-11T00:04:17.975+05:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"Authentication"}],"title":{"type":"text","$t":"Auth0 + Supabase: Enterprise SSO and Multi-Tenant RLS"},"content":{"type":"html","$t":"\u003Cimg alt=\"Auth0 Supabase SSO architecture diagram showing enterprise SAML connection routed through Auth0 Organizations into Supabase RLS\" height=\"450\" loading=\"eager\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgINc_xFvTZeFpaEAHBAaqsjXl_ZJWuXnVrYaL6RIOSKhMPr8Dk-CkliFzTjmwTvwt-xkJmOZctr1EkCZqwLmYKPMDC94iVSf9-1wGmWyGerpRp6F4un4P50alurXn1dP16sXlIqc3T85cHKwgdDHYNe8jcoIKc2YkrCYBcwD-fNWLrfHHIaESIMRseATY\/s800\/auth0-supabase-enterprise-sso-featured.webp\" title=\"Auth0 Supabase SSO architecture diagram showing enterprise SAML connection routed through Auth0 Organizations into Supabase RLS\" width=\"800\" \/\u003E\n\u003Cbr \/\u003E\n\u003Cp\u003EEvery Auth0 and Supabase tutorial you'll find online solves the same toy problem: one user, one row, one \u003Ccode\u003Eowner_id\u003C\/code\u003E column. That's fine until an enterprise prospect asks whether you support SSO through their Okta tenant, and you realize the token bridge you copied from a blog post has no idea what an organization even is. Suddenly you're staring at RLS policies built for individual users, trying to figure out how to isolate data by tenant instead.\u003C\/p\u003E\n\n\u003Cp\u003EThis is the gap almost nobody writes about. The official \u003Ca href=\"https:\/\/auth0.com\/blog\/using-nextjs-and-auth0-with-supabase\/\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"Using Next.js and Auth0 with Supabase\"\u003EAuth0 Next.js integration guide\u003C\/a\u003E is genuinely good at explaining the cryptographic handshake between the two platforms, but it stops at a single user ID. It never touches Auth0 Organizations, SAML routing, or tenant-scoped RLS, which is exactly what you need once you're selling to companies instead of individuals. If you've already read \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-multi-tenant-schema.html\" title=\"The Complete Guide to Multi-Tenant Database Design in Supabase\"\u003Eour complete guide to multi-tenant Supabase architecture\u003C\/a\u003E, this article is the authentication layer that feeds into it.\u003C\/p\u003E\n\n\u003Cdiv style=\"background: rgb(240, 247, 255); border-left: 4px solid rgb(26, 115, 232); border-radius: 4px; margin: 16px 0px; padding: 12px 16px;\"\u003E\n\u003Cstrong\u003EQuick Answer:\u003C\/strong\u003E Auth0 Supabase SSO for B2B apps requires routing enterprise SAML or OIDC connections into Auth0 Organizations, capturing \u003Ccode\u003Eevent.organization.id\u003C\/code\u003E in a Post-Login Action, and re-signing a fresh JWT with your Supabase signing secret since Auth0 and Supabase cannot share a signing algorithm directly. RLS policies then check \u003Ccode\u003Ecurrent_setting('request.jwt.claims', true)::json-\u0026gt;\u0026gt;'org_id'\u003C\/code\u003E instead of a user ID, which is what actually isolates tenants rather than individual rows.\n\u003C\/div\u003E\n\n\u003Ch2\u003ETable of Contents\u003C\/h2\u003E\n\u003Col\u003E\n  \u003Cli\u003E\u003Ca href=\"#why-b2c-tutorials-fail\"\u003EWhy the Standard Auth0 + Supabase Tutorial Falls Apart for B2B\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#auth0-organizations\"\u003ESetting Up Auth0 Organizations for Enterprise Customers\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#saml-oidc-connection\"\u003EMapping a SAML or OIDC Connection to an Organization\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#post-login-action\"\u003ECapturing Organization Context with a Post-Login Action\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#token-bridge\"\u003ERebuilding the Token Bridge for Multi-Tenant Claims\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#rls-policies\"\u003EWriting RLS Policies That Check Tenant ID, Not User ID\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#rbac-mapping\"\u003EMapping Auth0 Roles to RLS Permissions\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#quick-answers\"\u003EQuick Answers About Auth0 Supabase SSO\u003C\/a\u003E\u003C\/li\u003E\n  \u003Cli\u003E\u003Ca href=\"#faq\"\u003EFrequently Asked Questions\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ol\u003E\n\n\u003Ch2 id=\"why-b2c-tutorials-fail\"\u003EWhy the Standard Auth0 + Supabase Tutorial Falls Apart for B2B\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E the widely-referenced Auth0 and Supabase integration pattern was built for individual user ownership, not tenant isolation, and it silently breaks the moment you have two companies sharing the same database.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjovL4m65s7TQqrYsrs0xKkN4QsOHcqZsTAQZa9kUUGF2GCgwD20ewf2hyKonbgmma86RzhhMX_Hekfk9GMdMLNmROLcWHWUZE_xhLMU6fUoeayhZt4V4ByMUPjO13a2uT05Yn2dvCMHAjBt_qtP3ggLAnAex6dY7cAWqd5E1KbJCBkW-Alj_DgAAonVy8\/s800\/auth0-supabase-token-bridge-flow-diagram.webp\" \n  alt=\"Diagram of Auth0 Organizations SAML flow into Supabase RLS via token bridge\" \n  title=\"Architecture flow diagram (after intro, before Auth0 Organizations section) \" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Cp\u003EHere's the pattern almost every guide walks you through. A user logs in through Auth0. A Next.js \u003Ccode\u003EafterCallback\u003C\/code\u003E hook grabs their \u003Ccode\u003Esub\u003C\/code\u003E claim, signs a new JWT with your Supabase signing secret, and an RLS policy checks that a custom \u003Ccode\u003Eauth.user_id()\u003C\/code\u003E function matches the row's \u003Ccode\u003Euser_id\u003C\/code\u003E column. It works. It's also a B2C pattern wearing a B2B costume.\u003C\/p\u003E\n\n\u003Cp\u003EThe problem shows up the moment \"Acme Corp\" signs a contract and wants their ten employees, each authenticating through their own Okta tenant, to see Acme's data and nothing else. There's no organization in that token. There's no tenant boundary in that RLS policy. You'd have to bolt on a separate \u003Ccode\u003Eorganization_members\u003C\/code\u003E table and join against it on every single query, which is exactly the kind of join-heavy policy that \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-rls-custom-jwt-claims.html\" title=\"Writing RLS Policies with Custom JWT Claims (No More Joins)\"\u003Ecustom JWT claims exist to eliminate\u003C\/a\u003E.\u003C\/p\u003E\n\n\u003Cp\u003EDevelopers hit this wall constantly. One engineer on r\/Supabase described building organization-level features like team invites and per-org roles, then noting that \"Supabase auth handles individual users well but the organization layer doesn't exist\" at the platform level, they had to build it themselves. That's the honest state of things: Supabase gives you a rock-solid RLS engine, but the organizational modeling has to come from somewhere else, and Auth0 Organizations is that somewhere else for teams that need real enterprise SSO.\u003C\/p\u003E\n\n\u003Cp\u003EThe rest of this article covers the actual bridge: getting Auth0 Organizations set up, mapping SAML connections to them, and rewriting your token signing and RLS policies to check a tenant ID instead of a user ID.\u003C\/p\u003E\n\n\u003Ch2 id=\"auth0-organizations\"\u003ESetting Up Auth0 Organizations for Enterprise Customers\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E an Auth0 Organization is a container that represents one of your B2B customers, and it's the mechanism that lets you attach enterprise connections, roles, and branding to a specific company rather than to your whole user pool.\u003C\/p\u003E\n\n\u003Cp\u003EYou create one Organization per B2B customer, not per user. Acme Corp gets one Organization. Their employees get added as members of it, either manually, through an invitation flow, or automatically via Just-In-Time provisioning when they authenticate through Acme's own identity provider for the first time.\u003C\/p\u003E\n\n\u003Cp\u003ETwo things matter here that most tutorials skip entirely:\u003C\/p\u003E\n\n\u003Cul\u003E\n  \u003Cli\u003E\u003Cstrong\u003EOrganization ID is stable and unique.\u003C\/strong\u003E It's the value you'll eventually inject into your Supabase JWT as the tenant identifier, so treat it as the canonical tenant_id from day one, not an afterthought bolted on later.\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003EOrganizations support their own connection whitelist.\u003C\/strong\u003E You can restrict which identity providers are valid for a given Organization, which matters when Acme mandates that every login go through their Azure AD tenant and nothing else.\u003C\/li\u003E\n\u003C\/ul\u003E\n\n\u003Cp\u003EOnce the Organization exists, the next step is attaching an actual enterprise identity provider to it, which is where SAML and OIDC connections come in.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgDghAWK8rxAEqQmYl62dFiqHpx6Pvsxg9Hyqn-uKucGqBJvWXD4U5ETaFDcFh7hYtb2AmLt2rdGXDn4RCU0eOVGFnyP2UHGHhg0t_VhYgwChh7zXgIfvQ3uWMI0GUOEZJUoIrykgCC4_Fwy4oiltaplIKLAuYKQmZsQV-WWIqOuIUv9_ZflEiv5D7lqsA\/s800\/auth0-organizations-dashboard-acme-corp.webp\" \n  alt=\"Auth0 dashboard showing Acme Corp Organization with SAML connection attached\" \n  title=\"Auth0 Dashboard Organizations screenshot mockup\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"saml-oidc-connection\"\u003EMapping a SAML or OIDC Connection to an Organization\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E you configure a SAML or OIDC enterprise connection at the Auth0 tenant level, then attach it to the specific Organization it belongs to, so logins through that connection land the user inside the correct company's boundary automatically.\u003C\/p\u003E\n\n\u003Cp\u003EThe most common enterprise identity providers you'll be asked to support are Okta, Azure Active Directory, and Google Workspace, all of which Auth0 supports as enterprise connections through either SAML 2.0 or OIDC. The setup itself, exchanging metadata URLs, certificates, and entity IDs with the customer's IT team, is well documented in \u003Ca href=\"https:\/\/auth0.com\/docs\/authenticate\/identity-providers\/enterprise-identity-providers\/saml\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"Connect Your App to SAML Identity Providers\"\u003EAuth0's SAML connection docs\u003C\/a\u003E, so we won't repeat that here.\u003C\/p\u003E\n\n\u003Cp\u003EWhat matters for this article is the step after that: linking the connection to an Organization in the Auth0 dashboard, under that Organization's Connections tab. This is what makes Auth0 aware that \"anyone who logs in through this SAML connection belongs to Acme Corp,\" rather than dropping them into your undifferentiated general user pool.\u003C\/p\u003E\n\n\u003Cp\u003EIf Acme's IT department also wants automated user lifecycle management, Auth0 supports inbound \u003Ca href=\"https:\/\/auth0.com\/docs\/authenticate\/protocols\/scim\/inbound-scim-for-azure-ad-saml-connections\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"Inbound SCIM for Microsoft Entra ID SAML Connections\"\u003ESCIM provisioning for Azure AD SAML connections\u003C\/a\u003E, which automatically adds and removes Organization members as employees join or leave Acme. Most teams don't need this on day one, but it's worth knowing it exists before you build a custom provisioning script to solve a problem Auth0 already handles.\u003C\/p\u003E\n\n\u003Cp\u003EWith the connection mapped, a login through Acme's Okta tenant now resolves to a specific Organization inside Auth0. The next problem is getting that Organization ID out of Auth0's session and into the token your Next.js backend actually sees.\u003C\/p\u003E\n\n\u003Ch2 id=\"post-login-action\"\u003ECapturing Organization Context with a Post-Login Action\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E Auth0 exposes the Organization a user logged in through as \u003Ccode\u003Eevent.organization\u003C\/code\u003E inside a Post-Login Action, and you use that Action to write the Organization ID onto the ID token before it ever reaches your application.\u003C\/p\u003E\n\n\u003Cp\u003EThis is the step the standard Auth0-to-Supabase tutorials skip completely, because they're not authenticating through an Organization flow at all. When a user does log in through an Organization, Auth0's \u003Ca href=\"https:\/\/auth0.com\/docs\/actions\/reference\/post-login\/post-login-event-object\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"Event Object - Auth0 Actions\"\u003EPost-Login event object\u003C\/a\u003E includes \u003Ccode\u003Eevent.organization.id\u003C\/code\u003E and \u003Ccode\u003Eevent.organization.name\u003C\/code\u003E, which is exactly the metadata you need to carry forward.\u003C\/p\u003E\n\n\u003Cp\u003EHere's the Action, written in Node.js, that grabs it and attaches it as a custom claim:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-javascript\"\u003E\/**\n * Auth0 Post-Login Action\n * Captures the Organization ID and role, attaches them to the ID token.\n *\/\nexports.onExecutePostLogin = async (event, api) =\u0026gt; {\n  if (event.organization) {\n    \/\/ Namespaced claim required — Auth0 strips unnamespaced custom claims\n    api.idToken.setCustomClaim(\n      'https:\/\/rowistan.com\/org_id',\n      event.organization.id\n    );\n\n    \/\/ Pull the user's role within this Organization, if assigned\n    const roles = event.authorization?.roles || [];\n    api.idToken.setCustomClaim(\n      'https:\/\/rowistan.com\/org_role',\n      roles[0] || 'member'\n    );\n  }\n};\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003ETwo details here will save you hours of debugging. First, the claim namespace (\u003Ccode\u003Ehttps:\/\/rowistan.com\/org_id\u003C\/code\u003E) isn't optional decoration, Auth0 silently strips any custom claim that isn't namespaced as a URL. Second, this is written onto \u003Ccode\u003Eapi.idToken\u003C\/code\u003E, not \u003Ccode\u003Eapi.accessToken\u003C\/code\u003E. Auth0's access tokens follow OAuth conventions that reject arbitrary custom claims without extra configuration, while the ID token is built specifically to carry user and session context like this. If you're passing the access token to your Supabase bridge instead of the ID token, this claim simply won't be there, and you'll spend an afternoon convinced your Action isn't firing when it actually is.\u003C\/p\u003E\n\n\u003Ch2 id=\"token-bridge\"\u003ERebuilding the Token Bridge for Multi-Tenant Claims\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E because Auth0 and Supabase can't share a JWT signing secret or algorithm, your backend has to catch the Auth0 session, pull out the organization claim you just added, and sign a brand new token that Supabase will actually trust.\u003C\/p\u003E\n\n\u003Cp\u003EThis constraint isn't a workaround, it's a hard platform limitation on both sides. Supabase requires Auth0 tenants to run OIDC-conformant RS256 signing, and even then it won't natively verify a token signed with Auth0's key against your Postgres instance's expectations. As one developer put it on r\/node while wiring this up: \"neither Supabase or Auth0 allow for a custom signing secret to be set for their JWT. They also use different signing algorithms. Therefore, we need to extract the bits we need from Auth0's JWT, and sign our own to send to Supabase.\" That's the whole problem in one sentence, and it's exactly why a token bridge is required rather than optional.\u003C\/p\u003E\n\n\u003Cp\u003EHere's the multi-tenant version of that bridge, extending the standard pattern to carry the organization and role claims through:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Eimport jwt from 'jsonwebtoken';\nimport { getSession } from '@auth0\/nextjs-auth0';\n\n\/\/ Signs a Supabase-compatible JWT carrying Auth0's org context\nexport async function getSupabaseToken(req, res) {\n  const session = await getSession(req, res);\n  if (!session) throw new Error('No active Auth0 session');\n\n  const idToken = session.idToken; \/\/ decoded claims already attached\n  const orgId = session.user['https:\/\/rowistan.com\/org_id'];\n  const orgRole = session.user['https:\/\/rowistan.com\/org_role'];\n\n  if (!orgId) {\n    \/\/ User authenticated outside an Organization flow — reject or\n    \/\/ route to a \"no tenant assigned\" state, don't sign a token\n    \/\/ with a missing tenant boundary.\n    throw new Error('No organization context on session');\n  }\n\n  const supabasePayload = {\n    sub: session.user.sub,\n    role: 'authenticated',   \/\/ required so PostgREST doesn't fall back to anon\n    org_id: orgId,\n    org_role: orgRole,\n    aud: 'authenticated',\n    exp: Math.floor(Date.now() \/ 1000) + 3600,\n  };\n\n  return jwt.sign(supabasePayload, process.env.SUPABASE_SIGNING_SECRET, {\n    algorithm: 'HS256',\n  });\n}\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003ENotice the explicit \u003Ccode\u003Erole: 'authenticated'\u003C\/code\u003E claim. By default, a raw Auth0 JWT has no \u003Ccode\u003Erole\u003C\/code\u003E field at all, and PostgREST treats any token without one as the heavily restricted \u003Ccode\u003Eanon\u003C\/code\u003E Postgres role. This isn't a Supabase quirk, it's the documented default behavior, and it's a common reason developers see their queries silently return zero rows instead of an error: the query ran fine, it just ran as an anonymous user with no access.\u003C\/p\u003E\n\n\u003Cp\u003EThe \u003Ccode\u003Eorg_id\u003C\/code\u003E and \u003Ccode\u003Eorg_role\u003C\/code\u003E claims are the actual payoff of everything in the previous two sections. They're what turns this from a single-user token bridge into a tenant-aware one, and they're what your RLS policies will check next.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgP5P7JCOMN8Ts44GDE56SFgpE49CD2Zec24f5gZgs2FcUHD-UmxXMlibPVt-SboePXWi7Q7wCLxnRsqaKFvAJt5KxJlEGPcltYG7aybRcPHoRA5IF2OwXlZYlYdplc0BGq00fyUUoJ3Lb1xC94sbivzROws0p8QuCponPWP-4N5gwshXz4BT8Wq5L441I\/s800\/decoded-jwt-org-id-claim-supabase.webp\" \n  alt=\"Decoded JWT token showing org_id and role claims for Supabase RLS\" \n  title=\"Decoded JWT payload screenshot mockup\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"rls-policies\"\u003EWriting RLS Policies That Check Tenant ID, Not User ID\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E once \u003Ccode\u003Eorg_id\u003C\/code\u003E is embedded in the JWT, your RLS policies stop comparing a row's owner against the logged-in user and start comparing a row's tenant column against the organization on the token, which is what actually enforces B2B data isolation.\u003C\/p\u003E\n\n\u003Cp\u003EAssume a standard multi-tenant table with an \u003Ccode\u003Eorganization_id\u003C\/code\u003E column, the same pattern covered in \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-multi-tenant-schema.html\" title=\"The Complete Guide to Multi-Tenant Database Design in Supabase\"\u003Eour pillar guide on multi-tenant schema design\u003C\/a\u003E. Here's the policy that enforces isolation using the claim from the previous section:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Enable RLS on the table\nalter table public.projects enable row level security;\n\n-- SELECT: only rows belonging to the caller's organization\ncreate policy \"tenant_isolation_select\"\non public.projects\nfor select\nusing (\n  organization_id = (\n    current_setting('request.jwt.claims', true)::json -\u0026gt;\u0026gt; 'org_id'\n  )::uuid\n);\n\n-- INSERT: new rows must be tagged with the caller's own org_id\ncreate policy \"tenant_isolation_insert\"\non public.projects\nfor insert\nwith check (\n  organization_id = (\n    current_setting('request.jwt.claims', true)::json -\u0026gt;\u0026gt; 'org_id'\n  )::uuid\n);\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis is a meaningfully different policy from the B2C version. It's not asking \"does this user own this row,\" it's asking \"does this row belong to the organization on the caller's token.\" A single user can belong to exactly one organization per session, and every row they can see is scoped to that organization, full stop. There's no join against a membership table required, because the organization ID already lives on the token itself, courtesy of the Post-Login Action.\u003C\/p\u003E\n\n\u003Cp\u003EIf you're already running the \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-rls-tenant-id-in-fix.html\" title=\"The tenant_id IN() Trick That Fixes Slow RLS Queries\"\u003Etenant_id IN() pattern\u003C\/a\u003E for performance on high-volume tables, the same principle applies here: wrap the claim extraction in a \u003Ccode\u003Estable\u003C\/code\u003E SQL function so Postgres caches it per statement instead of re-parsing the JWT on every row.\u003C\/p\u003E\n\u003Cbr\u003E\n\u003Cimg \n  src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEj9gkNSffVFRiY7y8b3QNBB4UlvgY7n6RnhUja5H5YksrClfbQTVgyDdePXLpfuNX4-3ewlBtnJ_Sy2M8sZ2TyfaWwEAgtxGeduLL6bjElfpv0VwRK_gsWXVg_pHBVbJEOdNMigWAeHPkvxEZnyJ6yVn2tTKvzVUi5hL7kjMrH25nT-oa4f9kmI5ypuYyo\/s800\/supabase-rls-tenant-isolation-policies.webp\" \n  alt=\"Supabase dashboard showing tenant isolation RLS policies on the projects table\" \n  title=\"Supabase RLS Policies screenshot mockup\" \n  width=\"800\" \n  height=\"450\" \n  loading=\"lazy\"\n\/\u003E\n\u003Cbr\u003E\n\u003Ch2 id=\"rbac-mapping\"\u003EMapping Auth0 Roles to RLS Permissions\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E the \u003Ccode\u003Eorg_role\u003C\/code\u003E claim you captured earlier lets you restrict specific operations, like inserts or deletes, to admins within an organization while leaving read access open to everyone in that org.\u003C\/p\u003E\n\n\u003Cp\u003EEnterprise customers rarely want a flat permission model. Acme's IT admin wants to manage billing and invite teammates; their support staff just needs to view tickets. Auth0 Organizations supports assigning roles to members, and since you're already passing \u003Ccode\u003Eorg_role\u003C\/code\u003E through the token bridge, restricting write access is a one-line addition to the policy:\u003C\/p\u003E\n\n\u003Cpre\u003E\u003Ccode class=\"language-sql\"\u003E-- Only admins within the organization can delete projects\ncreate policy \"tenant_admin_delete\"\non public.projects\nfor delete\nusing (\n  organization_id = (\n    current_setting('request.jwt.claims', true)::json -\u0026gt;\u0026gt; 'org_id'\n  )::uuid\n  and\n  (current_setting('request.jwt.claims', true)::json -\u0026gt;\u0026gt; 'org_role') = 'admin'\n);\u003C\/code\u003E\u003C\/pre\u003E\n\n\u003Cp\u003EThis is where SAML-sourced Active Directory groups typically enter the picture. If Acme's IT department maps their internal \"IT Admins\" AD group to an Auth0 role during SAML attribute mapping, that role flows into \u003Ccode\u003Eevent.authorization.roles\u003C\/code\u003E in the Post-Login Action from earlier, straight through to this policy, with zero manual role assignment on your end. It's the same chain the whole article has been building: SAML connection to Organization to Post-Login Action to signed JWT to RLS policy, each step handing the next one exactly what it needs.\u003C\/p\u003E\n\n\u003Ch2 id=\"quick-answers\"\u003EQuick Answers About Auth0 Supabase SSO\u003C\/h2\u003E\n\n\u003Ch3\u003EWhat Causes the Auth0 to Supabase Integration to Break for B2B Apps?\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003ESimply put,\u003C\/strong\u003E the widely-copied integration tutorial only signs a user ID into the Supabase JWT, with no concept of an organization or tenant. Auth0 and Supabase also can't share a JWT signing secret, so a backend bridge has to re-sign the token, and if that bridge never captures organization context in the first place, there's nothing to isolate tenants against at the database layer. This matters most the moment you onboard your first customer with more than one employee.\u003C\/p\u003E\n\n\u003Ch3\u003EAuth0 Supabase SSO at a Glance\u003C\/h3\u003E\n\u003Cdiv class=\"rw-table-wrap\"\u003E\n\u003Ctable class=\"rw-table\"\u003E\n  \u003Cthead\u003E\n    \u003Ctr\u003E\u003Cth\u003EAspect\u003C\/th\u003E\u003Cth\u003EDetails\u003C\/th\u003E\u003C\/tr\u003E\n  \u003C\/thead\u003E\n  \u003Ctbody\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ESymptom\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EEnterprise SSO works for login, but RLS still isolates by user, not company\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ERoot Cause\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EStandard token bridge never captures Auth0's \u003Ccode\u003Eevent.organization.id\u003C\/code\u003E\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EFix\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EPost-Login Action injects \u003Ccode\u003Eorg_id\u003C\/code\u003E into the ID token; RLS checks it via \u003Ccode\u003Erequest.jwt.claims\u003C\/code\u003E\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003ERequired Claim Location\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EID token, not access token — Auth0 strips unnamespaced custom claims from access tokens\u003C\/td\u003E\u003C\/tr\u003E\n    \u003Ctr\u003E\u003Ctd data-label=\"Aspect\"\u003EApplies To\u003C\/td\u003E\u003Ctd data-label=\"Details\"\u003EAuth0 Organizations + any current Supabase project using third-party JWT verification\u003C\/td\u003E\u003C\/tr\u003E\n  \u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003C\/div\u003E\n\n\u003Ch3\u003EWhen Does This Apply?\u003C\/h3\u003E\n\u003Cp\u003EThis applies once you're selling to companies rather than individuals and need real SAML or OIDC SSO through providers like Okta or Azure AD. If every user in your app is an independent individual account, the standard single-user Auth0 to Supabase bridge is still the right call, don't add Organizations complexity you don't need yet.\u003C\/p\u003E\n\n\u003Ch3\u003EPros and Cons of Bridging Auth0 Organizations into Supabase RLS\u003C\/h3\u003E\n\u003Cul\u003E\n  \u003Cli\u003E\u003Cstrong\u003EPro:\u003C\/strong\u003E Enterprise-grade SAML\/OIDC SSO without building a custom identity provider integration from scratch\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003EPro:\u003C\/strong\u003E Tenant isolation lives in the JWT claim, avoiding expensive membership-table joins in every RLS policy\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003EPro:\u003C\/strong\u003E Role mapping from AD groups flows through automatically once SAML attribute mapping is configured\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cul\u003E\n  \u003Cli\u003E\u003Cstrong\u003ECon:\u003C\/strong\u003E Adds a real backend dependency, your Next.js token-signing route becomes a single point of failure for every database request\u003C\/li\u003E\n  \u003Cli\u003E\u003Cstrong\u003ECon:\u003C\/strong\u003E Supabase's native SAML SSO creates separate accounts for users who already have a password-based account, so pick one auth path per user and stick with it\u003C\/li\u003E\n\u003C\/ul\u003E\n\n\u003Ch2 id=\"faq\"\u003EFrequently Asked Questions\u003C\/h2\u003E\n\n\u003Ch3\u003EDoes Supabase support SAML natively without Auth0?\u003C\/h3\u003E\n\u003Cp\u003EYes, Supabase offers native enterprise SSO with SAML 2.0 on paid plans. It's a valid option if you don't already use Auth0, but it doesn't provide Auth0's Organizations model, SCIM provisioning, or broader identity provider ecosystem.\u003C\/p\u003E\n\n\u003Ch3\u003ECan I use Supabase only for the database and Auth0 for everything else?\u003C\/h3\u003E\n\u003Cp\u003EYes, this is the exact pattern this article covers. Auth0 handles identity, organizations, and SSO; Supabase handles Postgres and RLS. The token bridge is what connects the two, since Supabase never natively verifies an Auth0-issued JWT.\u003C\/p\u003E\n\n\u003Ch3\u003EWhy does my Auth0 custom claim disappear from the Supabase token?\u003C\/h3\u003E\n\u003Cp\u003EAuth0 silently strips any custom claim that isn't namespaced as a full URL, and it doesn't attach arbitrary custom claims to access tokens by default. Use a namespaced claim like \u003Ccode\u003Ehttps:\/\/yourdomain.com\/org_id\u003C\/code\u003E and set it on the ID token, not the access token.\u003C\/p\u003E\n\n\u003Ch3\u003EWhat JWT signing algorithm does Supabase require from Auth0?\u003C\/h3\u003E\n\u003Cp\u003ESupabase requires the Auth0 tenant to be OIDC conformant and use RS256. It does not support HS256 or PS256 signed Auth0 tokens for third-party auth integration.\u003C\/p\u003E\n\n\u003Ch3\u003EHow do I get a user's organization ID into my Postgres RLS policy?\u003C\/h3\u003E\n\u003Cp\u003ECapture \u003Ccode\u003Eevent.organization.id\u003C\/code\u003E in an Auth0 Post-Login Action, set it as a custom ID token claim, sign a new Supabase-compatible JWT containing that claim in your backend, then read it in RLS with \u003Ccode\u003Ecurrent_setting('request.jwt.claims', true)::json-\u0026gt;\u0026gt;'org_id'\u003C\/code\u003E.\u003C\/p\u003E\n\n\u003Ch3\u003EIs multi-tenant SSO with SAML supported through Auth0 Organizations?\u003C\/h3\u003E\n\u003Cp\u003EYes. You attach a SAML enterprise connection to a specific Auth0 Organization, so logins through that connection are scoped to that company automatically, which is the standard way to support one SSO connection per enterprise customer.\u003C\/p\u003E\n\n\u003Ch3\u003EDo I need SCIM provisioning to use Auth0 Organizations?\u003C\/h3\u003E\n\u003Cp\u003ENo, SCIM is optional. It automates adding and removing Organization members when a customer's IT department changes staff, but manual invitations or Just-In-Time provisioning on first login work fine for smaller enterprise accounts.\u003C\/p\u003E\n\n\u003Ch3\u003EWill mixing Supabase native SAML and Auth0 SSO cause account duplication?\u003C\/h3\u003E\n\u003Cp\u003EIf you use Supabase's native SAML and a user already has a password-based Supabase account, signing in through SAML creates a second, separate account rather than linking to the existing one. This is specific to Supabase's native SSO, not the Auth0 bridge pattern in this article.\u003C\/p\u003E\n\n\u003Ch2\u003EConclusion\u003C\/h2\u003E\n\u003Cp\u003EThe gap in nearly every Auth0 and Supabase guide isn't a missing code snippet, it's a missing layer. Token bridging alone gets you authentication. Auth0 Organizations, SAML connection mapping, and a Post-Login Action that captures \u003Ccode\u003Eevent.organization.id\u003C\/code\u003E are what get you real multi-tenant authorization on top of it. Once that organization claim exists on your signed JWT, your RLS policies stop guessing at ownership and start enforcing actual tenant boundaries, the same boundaries covered in more depth in \u003Ca href=\"https:\/\/blog.rowistan.com\/2026\/08\/supabase-multi-tenant-schema.html\" title=\"The Complete Guide to Multi-Tenant Database Design in Supabase\"\u003Eour multi-tenant schema design guide\u003C\/a\u003E.\u003C\/p\u003E\n\n\u003Cp\u003EIf you're rolling this out, start with one enterprise customer's SAML connection end to end before generalizing the Action and token bridge for others; it's much easier to debug a missing claim with one Organization in play than five. Bookmark this one, you'll likely be back here the first time a prospect's IT team asks about SCIM.\u003C\/p\u003E\n\n\u003Cscript type=\"application\/ld+json\"\u003E\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Does Supabase support SAML natively without Auth0?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes, Supabase offers native enterprise SSO with SAML 2.0 on paid plans. It's a valid option if you don't already use Auth0, but it doesn't provide Auth0's Organizations model, SCIM provisioning, or broader identity provider ecosystem.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I use Supabase only for the database and Auth0 for everything else?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes, this is the exact pattern this article covers. Auth0 handles identity, organizations, and SSO; Supabase handles Postgres and RLS. The token bridge is what connects the two, since Supabase never natively verifies an Auth0-issued JWT.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does my Auth0 custom claim disappear from the Supabase token?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Auth0 silently strips any custom claim that isn't namespaced as a full URL, and it doesn't attach arbitrary custom claims to access tokens by default. Use a namespaced claim like https:\/\/yourdomain.com\/org_id and set it on the ID token, not the access token.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What JWT signing algorithm does Supabase require from Auth0?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Supabase requires the Auth0 tenant to be OIDC conformant and use RS256. It does not support HS256 or PS256 signed Auth0 tokens for third-party auth integration.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I get a user's organization ID into my Postgres RLS policy?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Capture event.organization.id in an Auth0 Post-Login Action, set it as a custom ID token claim, sign a new Supabase-compatible JWT containing that claim in your backend, then read it in RLS with current_setting('request.jwt.claims', true)::json-\u003E\u003E'org_id'.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Is multi-tenant SSO with SAML supported through Auth0 Organizations?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes. You attach a SAML enterprise connection to a specific Auth0 Organization, so logins through that connection are scoped to that company automatically, which is the standard way to support one SSO connection per enterprise customer.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Do I need SCIM provisioning to use Auth0 Organizations?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"No, SCIM is optional. It automates adding and removing Organization members when a customer's IT department changes staff, but manual invitations or Just-In-Time provisioning on first login work fine for smaller enterprise accounts.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Will mixing Supabase native SAML and Auth0 SSO cause account duplication?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"If you use Supabase's native SAML and a user already has a password-based Supabase account, signing in through SAML creates a second, separate account rather than linking to the existing one. This is specific to Supabase's native SSO, not the Auth0 bridge pattern in this article.\"\n      }\n    }\n  ]\n}\n\u003C\/script\u003E"},"link":[{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/8313036209488357074"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/1660802944707181277\/posts\/default\/8313036209488357074"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.rowistan.com\/2026\/09\/auth0-supabase-enterprise-sso.html","title":"Auth0 + Supabase: Enterprise SSO and Multi-Tenant RLS"}],"author":[{"name":{"$t":"Mubeen Ahmad"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/10002112912394235618"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"32","src":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjJWcdkU3PHEtcRfSiZ-5rRkFL-ncfv3kSs5TudUdK_ODzC84jxOgFloVde7OLlqS62bS_taAX4V_kctnNnQcNn4MeZPl_63w3IaAwwD-re8JN2N_6QYw_UbM7DV6Y8jj1pPOWmLNBNjGE9_a5sq7aPZhGrypPJ3PhbUJB6uS7Iph4weUM\/s1600\/Rowistan-logo.png"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgINc_xFvTZeFpaEAHBAaqsjXl_ZJWuXnVrYaL6RIOSKhMPr8Dk-CkliFzTjmwTvwt-xkJmOZctr1EkCZqwLmYKPMDC94iVSf9-1wGmWyGerpRp6F4un4P50alurXn1dP16sXlIqc3T85cHKwgdDHYNe8jcoIKc2YkrCYBcwD-fNWLrfHHIaESIMRseATY\/s72-c\/auth0-supabase-enterprise-sso-featured.webp","height":"72","width":"72"}}]}}