<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>UnitAutogen Blog</title>
  <subtitle>Testing PostgreSQL and SQL Server: Row-Level Security, pgTAP, tSQLt, and automated database test generation.</subtitle>
  <link href="https://unitautogen.com/blog/"/>
  <link rel="self" type="application/atom+xml" href="https://unitautogen.com/feed.xml"/>
  <id>https://unitautogen.com/</id>
  <updated>2026-06-22T15:00:00+00:00</updated>
  <author><name>Munaf Khatri</name></author>

  <entry>
    <title>Testing Supabase RLS locally when it depends on JWT custom claims</title>
    <link href="https://unitautogen.com/blog/testing-rls-with-jwt-custom-claims-locally.html"/>
    <id>https://unitautogen.com/blog/testing-rls-with-jwt-custom-claims-locally.html</id>
    <updated>2026-06-22T15:00:00+00:00</updated>
    <published>2026-06-22T15:00:00+00:00</published>
    <author><name>Munaf Khatri</name></author>
    <summary>RLS that reads JWT custom claims works on Supabase Cloud but fails in local Studio, because the local Dashboard isn't GoTrue-integrated and auth.jwt() comes back with empty claims. Here's how to test it correctly at the database level.</summary>
    <content type="html"><![CDATA[
<p>If your Row-Level Security depends on custom JWT claims &mdash; an <code>org_id</code> for multi-tenancy, a role claim for RBAC &mdash; you've probably hit this: it works on Supabase Cloud, then your local tests return empty tables or "new row violates row-level security policy." The policy is fine; the problem is how the claims reach <code>auth.jwt()</code> locally.</p>
<p><strong>Why it works on Cloud but not in local Studio.</strong> On a real request GoTrue mints a JWT (running your access-token hook, so the custom claims are in it), PostgREST puts the decoded payload into the <code>request.jwt.claims</code> setting, and your policy's <code>auth.jwt()</code> just reads that setting. The local Studio Dashboard isn't tightly GoTrue-integrated, so impersonating a user doesn't carry your custom claims &mdash; <code>auth.jwt()</code> returns them empty, every policy that reads them evaluates false, and you get an empty result.</p>
<p><strong>The fix: test at the database level.</strong> <code>auth.jwt()</code> only reads <code>request.jwt.claims</code>, so reproduce a real request by setting it yourself, in a transaction, as the right role:</p>
<pre><code>begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"...","role":"authenticated","app_metadata":{"org_id":"org-a","role":"admin"}}', true);
-- auth.jwt()-&gt;'app_metadata'-&gt;&gt;'org_id' now returns 'org-a' inside your policy
select * from documents;     -- runs under RLS as this exact identity
rollback;</code></pre>
<p>Copy the exact claim shape from a real API session's token so it matches your hook. For multi-tenancy, run it again with a <em>different</em> org and assert isolation &mdash; a real rival tenant, not a no-org outsider.</p>
<p>Three things that cause exactly this: (1) <code>set_config(..., true)</code> is transaction-local, so it must be in the same transaction as the query; (2) you must <code>set role authenticated</code> / <code>anon</code>, or you stay superuser and RLS is bypassed; (3) the JSON nesting must match what the policy reads. Bonus: unlike the Dashboard, this runs in CI.</p>
<p>Full post: <a href="https://unitautogen.com/blog/testing-rls-with-jwt-custom-claims-locally.html">unitautogen.com</a>. I build <a href="https://github.com/unitautogen/rlsautotest">rlsautotest</a> to generate these per-identity RLS tests automatically, at the DB level so they're immune to the Studio/GoTrue gap.</p>
]]></content>
  </entry>

  <entry>
    <title>Most Postgres RLS ships untested. Here's how to test it with pgTAP.</title>
    <link href="https://unitautogen.com/blog/most-postgres-rls-ships-untested.html"/>
    <id>https://unitautogen.com/blog/most-postgres-rls-ships-untested.html</id>
    <updated>2026-06-22T12:00:00+00:00</updated>
    <published>2026-06-22T12:00:00+00:00</published>
    <author><name>Munaf Khatri</name></author>
    <summary>How to actually test Postgres / Supabase Row-Level Security with pgTAP: becoming each identity, USING vs WITH CHECK, "0 rows" vs "denied", the seed-data trap, and the cases that bite you.</summary>
    <content type="html"><![CDATA[
<p>Row-Level Security is the security boundary of most Postgres and Supabase apps. It's the one thing standing between "users see their own data" and a cross-tenant leak. And yet most RLS I see in the wild has zero tests &mdash; not because people don't care, but because testing RLS well is fiddly, and a test that <em>looks</em> like it passes often proves nothing at all.</p>

<p>This post is about how to actually test RLS with <a href="https://pgtap.org">pgTAP</a>: the mechanics, the traps, and the handful of cases that bite you. No tooling required to follow along &mdash; just <code>psql</code> and pgTAP (which Supabase ships, and you can <code>CREATE EXTENSION pgtap</code> anywhere else).</p>

<h2>Why RLS is hard to test</h2>
<p>Four things make RLS testing different from normal unit testing:</p>
<p><strong>1. You have to <em>become</em> each identity.</strong> A policy like <code>USING (owner = auth.uid())</code> behaves completely differently depending on who's asking. So a real test has to impersonate each one &mdash; anonymous, authenticated user A, authenticated user B, the service role &mdash; and check what each can actually do. In Postgres/Supabase terms that's <code>SET ROLE</code> plus setting the request's JWT claims.</p>
<p><strong>2. <code>USING</code> and <code>WITH CHECK</code> are not the same thing.</strong> <code>USING</code> controls which rows you can see/affect (SELECT, UPDATE, DELETE). <code>WITH CHECK</code> controls which rows you're allowed to write (INSERT, and the new value on UPDATE). A table can be perfectly locked down for reads and wide open for writes, or vice versa. You have to test both directions.</p>
<p><strong>3. "Denied" has two completely different shapes.</strong> When someone can't do something, it's either filtered to <strong>zero rows</strong> (RLS silently hides rows, no error) or a hard <strong>permission error</strong> (<code>42501</code> &mdash; the role doesn't even have the table grant). These mean different things, and conflating them hides bugs. A test that just checks "got an error" will miss an RLS policy that's silently returning everyone's rows, because that path doesn't error &mdash; it returns data.</p>
<p><strong>4. The seed-data trap &mdash; this is the big one.</strong> A test only proves something if the data driving it actually exercises the policy. If your table is empty, "user B sees 0 rows" passes whether or not the policy works. If the row you seeded isn't actually owned by the user you're impersonating, "owner sees their row" fails &mdash; or worse, accidentally passes for the wrong reason. The data has to match the identity and the predicate, or the green checkmark is a lie.</p>

<h2>A real pgTAP RLS test</h2>
<p>Here's an owner-scoped <code>documents</code> table tested by hand. The shape is Arrange &rarr; Act &rarr; Assert, wrapped in a transaction that rolls back so tests don't pollute each other.</p>
<pre><code>begin;
select plan(3);

-- ARRANGE: seed as the test-runner role (superuser/owner -- bypasses RLS).
-- Two users, one document owned by user A.
insert into auth.users (id) values
  ('00000000-0000-0000-0000-00000000000a'),
  ('00000000-0000-0000-0000-00000000000b');
insert into documents (owner, title)
  values ('00000000-0000-0000-0000-00000000000a', 'A''s doc');

-- ACT/ASSERT as user A (the owner) -- should see exactly their row
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"00000000-0000-0000-0000-00000000000a","role":"authenticated"}', true);
select is( (select count(*) from documents)::int, 1, 'owner sees their own row' );

-- as user B (a different authenticated user) -- should see nothing
select set_config('request.jwt.claims',
  '{"sub":"00000000-0000-0000-0000-00000000000b","role":"authenticated"}', true);
select is( (select count(*) from documents)::int, 0, 'another user sees nothing' );

-- as anon -- should see nothing
reset role;
set local role anon;
select set_config('request.jwt.claims', '', true);
select is( (select count(*) from documents)::int, 0, 'anon sees nothing' );

select * from finish();
rollback;</code></pre>
<p>Notice the two things that make this <em>mean</em> something: we seeded a row that is genuinely owned by user A (so "owner sees their row" is a real assertion, not a fluke against empty data), and the negative case is a real, different authenticated user &mdash; not just "logged out."</p>

<h2>The cases that bite you</h2>
<p>Once the basic shape is in place, these are the ones that catch people:</p>
<p><strong>INSERT is governed by <code>WITH CHECK</code>, which constrains the row, not the caller.</strong> A user who can't see another tenant's data can usually still insert <em>their own</em> row &mdash; and that's correct, not a hole. The actual hole is <code>WITH CHECK (true)</code> (anyone can write anything) or RLS being off entirely. So an INSERT test should assert two things: the caller can insert a row that satisfies the check, and cannot insert one that violates it (e.g. a row attributed to someone else).</p>
<pre><code>-- a different user inserting their OWN row: allowed (WITH CHECK passes)
select lives_ok(
  $$ insert into documents (owner, title) values (auth.uid(), 'mine') $$,
  'user can insert their own row'
);
-- inserting a row owned by someone ELSE: must be rejected
select throws_ok(
  $$ insert into documents (owner, title)
       values ('00000000-0000-0000-0000-00000000000a', 'not mine') $$,
  '42501', null, 'user cannot insert a row for another owner'
);</code></pre>
<p><strong>Tenant isolation needs a real rival tenant.</strong> The meaningful negative test for <code>org_id = (auth.jwt() -&gt; 'app_metadata' -&gt;&gt; 'org_id')::uuid</code> isn't an outsider with no org &mdash; it's a legitimate user of a <em>different</em> org. That's the test that catches a subtly-wrong predicate like <code>org_id IS NOT NULL</code>, which a no-org outsider would pass right through.</p>
<p><strong>RBAC via SECURITY DEFINER functions.</strong> Policies that delegate to <code>authorize('documents.read')</code> or <code>has_role('admin')</code> are opaque to a black-box test &mdash; you can't always drive them through the JWT. You test the <em>wiring</em>: set up the state the function reads (or control the function), then assert that the policy is allowed when it returns true and denied when it returns false.</p>
<p><strong>Self-referential policies.</strong> A policy that queries its own table (a recursive folder tree, say) can throw <code>infinite recursion detected in policy</code>, which locks out <em>every</em> client role. Worth an explicit "the table is even readable" test.</p>

<h2>Doing this for a whole schema</h2>
<p>None of the above is hard for one table. The problem is that a real app has dozens of tables, four commands each, and several identities &mdash; and every single combination needs its own seeded precondition to mean anything. That's a lot of careful, repetitive SQL, and the failure mode is silent: a test that passes against the wrong data tells you you're safe when you're not.</p>
<p>That repetition is what pushed me to write a generator. <a href="https://github.com/unitautogen/rlsautotest">rlsautotest</a> reads your policies straight from the catalog and emits this pgTAP for you, including the seed data that matches each policy and identity. It's free and open source (Apache-2.0). But whether you generate the tests or hand-write them, the principles above are the part that matters: become each identity, test both <code>USING</code> and <code>WITH CHECK</code>, distinguish "0 rows" from "denied," and make sure your seed data actually exercises the policy.</p>
<p>Your RLS is your security boundary. Test it like one.</p>
]]></content>
  </entry>
</feed>
