<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Munaf Khatri</title>
    <description>The latest articles on DEV Community by Munaf Khatri (@munaf-khatri).</description>
    <link>https://dev.to/munaf-khatri</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4005429%2Fb1ff54ae-c2a2-405f-ac2b-a5530b3c0dde.png</url>
      <title>DEV Community: Munaf Khatri</title>
      <link>https://dev.to/munaf-khatri</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/munaf-khatri"/>
    <language>en</language>
    <item>
      <title>HIPAA compliance evidence for the database layer on Supabase: RLS, functions, triggers and audit logs</title>
      <dc:creator>Munaf Khatri</dc:creator>
      <pubDate>Sat, 31 Oct 2026 15:52:28 +0000</pubDate>
      <link>https://dev.to/munaf-khatri/hipaa-compliance-evidence-for-the-database-layer-on-supabase-rls-functions-triggers-and-audit-1jc9</link>
      <guid>https://dev.to/munaf-khatri/hipaa-compliance-evidence-for-the-database-layer-on-supabase-rls-functions-triggers-and-audit-1jc9</guid>
      <description>&lt;p&gt;Supabase signs a BAA and has a High Compliance setting for projects that store PHI, which handles the platform. The database layer built on it is a separate matter. RLS policies, functions, triggers and audit tables are written by the project team, and a HIPAA review expects evidence that they protect PHI. Supabase’s &lt;a href="https://supabase.com/docs/guides/security/shared-responsibility-model" rel="noopener noreferrer"&gt;shared responsibility model&lt;/a&gt; puts them on the customer’s side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Supabase covers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Encryption at rest and in transit, the BAA, and project settings. The HIPAA guide asks for these to be on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Point in Time Recovery&lt;/li&gt;
&lt;li&gt;SSL enforcement&lt;/li&gt;
&lt;li&gt;network restrictions&lt;/li&gt;
&lt;li&gt;connection logging&lt;/li&gt;
&lt;li&gt;MFA on every account with access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Security Advisor warns when any of them is off, and the linter flags patterns like a table in public with RLS off. Neither one runs the policies. They cannot show whether a nurse at one clinic can read another clinic’s visits, or whether a deleted record left a row in the audit table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the project owner has to show&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A HIPAA review asks three questions about the database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who can reach which patient rows - 164.312(a)(1)&lt;/li&gt;
&lt;li&gt;is every change to that data recorded - 164.312(b)&lt;/li&gt;
&lt;li&gt;can anyone alter that record afterwards - 164.312(c)(1)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;164.308(a)(8) adds that this is evaluated again whenever the environment changes. On Supabase, the answers live in SQL the team wrote. A screenshot of the policies does not show what the database does when authenticated calls one of those functions. Only running it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Producing the evidence with &lt;code&gt;unitautogen hipaa&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unitautogen hipaa&lt;/code&gt; runs against the local Supabase stack, which is built from the migrations, so no patient data is involved:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bash&lt;br&gt;
supabase start&lt;br&gt;
unitautogen --supabase hipaa --schema public&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
It generates the tests and the rows they need, runs them as each role the app uses, and rolls the rows back. The tests are written to &lt;code&gt;supabase/tests/seccover/security/&lt;/code&gt;, where &lt;code&gt;supabase test db&lt;/code&gt; picks them up. The report goes to &lt;code&gt;supabase/tests/seccover/hipaa/public/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it checks&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Access. Functions, views and triggers that run as their owner bypass RLS. Each one is called as two different tenants, with data on both sides, and marked as holding or leaking. Open policies, column grants and PHI leaking through error messages are checked too.&lt;/li&gt;
&lt;li&gt;Audit log. For every PHI table, and for each of insert, update and delete: did the change leave an audit row, and does that row record who made it. Connection logging does not answer this. It records who connected, not which rows changed.&lt;/li&gt;
&lt;li&gt;Tampering. For each audit table and each client role: can that role edit, delete or forge rows. A common hole is a security definer function exposed through .rpc() that writes to the audit table, letting any logged-in user write anything into it.&lt;/li&gt;
&lt;li&gt;Identity. Policies depend on auth.uid(), auth.jwt() or a session setting. The report shows whether a client session can change what those return.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What to fix&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The report opens with a ranked “What to fix” list. There is one row per object, with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the priority: fix now or review&lt;/li&gt;
&lt;li&gt;the HIPAA section&lt;/li&gt;
&lt;li&gt;what was found&lt;/li&gt;
&lt;li&gt;the change that closes it&lt;/li&gt;
&lt;li&gt;a link to the evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2dx60gmi7a4rkfi19jgy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2dx60gmi7a4rkfi19jgy.png" alt=" " width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When many functions are callable only through PostgreSQL’s default PUBLIC grant, the report says so once above the list and gives the single fix:&lt;br&gt;
&lt;code&gt;sql&lt;br&gt;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every finding is backed by a &lt;strong&gt;pgTAP&lt;/strong&gt; test that can be committed and re-run. Each result is mapped to its 164.312 section, and the report is stamped with a SHA-256 of every file behind it, so any later edit shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;what to do on every migration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Schema changes reach Supabase as migrations in a PR. Add the hipaa command to the CI job that already runs supabase test db:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bash&lt;br&gt;
supabase start&lt;br&gt;
supabase test db&lt;br&gt;
unitautogen --supabase hipaa --schema public&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every PR then produces a new report, dated and stamped, kept as a build artifact. &lt;strong&gt;164.308(a)(8)&lt;/strong&gt; asks for an evaluation each time the environment changes. Each of these reports is that evaluation for the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The report gives evidence for RLS policies, functions, triggers and audit logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not cover:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;encryption, backups and network settings - Supabase’s part&lt;/p&gt;

&lt;p&gt;More on unitautogen hipaa: &lt;a href="https://unitautogen.com/postgresql/unitautogen/hipaa/" rel="noopener noreferrer"&gt;https://unitautogen.com/postgresql/unitautogen/hipaa/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>security</category>
      <category>supabase</category>
      <category>hipaa</category>
    </item>
    <item>
      <title>RLS fails silently. Here's how to actually test your Supabase policies.</title>
      <dc:creator>Munaf Khatri</dc:creator>
      <pubDate>Fri, 10 Jul 2026 11:52:00 +0000</pubDate>
      <link>https://dev.to/munaf-khatri/rls-fails-silently-heres-how-to-actually-test-your-supabase-policies-p6m</link>
      <guid>https://dev.to/munaf-khatri/rls-fails-silently-heres-how-to-actually-test-your-supabase-policies-p6m</guid>
      <description>&lt;p&gt;A wrong RLS policy doesn't throw an error, it just returns the wrong rows, so it ships untested and you find out when data leaks. rlsautotest generates the pgTAP tests and the seed data straight from your policies, so you can catch it. Free and open-source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem: RLS fails quietly&lt;/strong&gt;&lt;br&gt;
Row-Level Security is what keeps one tenant's data from another's.&lt;br&gt;
When it's wrong, it doesn't error. It returns the wrong rows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Too strict → empty results → your app looks broken.&lt;/li&gt;
&lt;li&gt;Too loose → rows the user shouldn't see → your app leaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither raises an exception. You find out from a confused user, or a security report.&lt;br&gt;
And "it works in the SQL editor" proves nothing. The SQL editor bypasses RLS, so your policies were never exercised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why nobody tests it&lt;/strong&gt;&lt;br&gt;
Supabase's own docs admit writing pgTAP tests for RLS is "inaccessible to most web developers."&lt;br&gt;
To test one policy by hand you have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a few users (owner, other user, anon, role-holder)&lt;/li&gt;
&lt;li&gt;insert rows owned by different users&lt;/li&gt;
&lt;li&gt;become each identity (set JWT claims + role)&lt;/li&gt;
&lt;li&gt;run SELECT / INSERT / UPDATE / DELETE as each&lt;/li&gt;
&lt;li&gt;assert exactly which rows each can and can't touch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per table. Per policy.&lt;/p&gt;

&lt;p&gt;And the &lt;em&gt;seed data&lt;/em&gt; matters as much as the test. Assert "the owner sees their row" against an empty table and it passes while proving nothing.&lt;br&gt;
Most teams take one look and skip it. So the actual security boundary ships untested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a real RLS test checks&lt;/strong&gt;&lt;br&gt;
From each identity's point of view:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;owner&lt;/strong&gt; → sees and changes their own row&lt;br&gt;
&lt;strong&gt;other user / tenant&lt;/strong&gt; → can't see it at all&lt;br&gt;
&lt;strong&gt;anon **→ blocked&lt;br&gt;
**role-holder&lt;/strong&gt; → exactly the access the role grants&lt;/p&gt;

&lt;p&gt;All against a row really owned by the identity under test.&lt;br&gt;
And "denied" has two flavors a good test keeps apart:&lt;/p&gt;

&lt;p&gt;row-level filtering → &lt;strong&gt;zero rows&lt;/strong&gt;&lt;br&gt;
missing grant → &lt;strong&gt;permission error&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a lot of careful setup, for every policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate it instead&lt;/strong&gt;&lt;br&gt;
I got tired of doing this by hand, so I built &lt;strong&gt;rlsautotest **(open-source, Apache-2.0).&lt;br&gt;
**Repo&lt;/strong&gt;: &lt;a href="https://github.com/unitautogen/rlsautotest#readme" rel="noopener noreferrer"&gt;https://github.com/unitautogen/rlsautotest#readme&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not Supabase-only. It runs on any Postgres (Neon, RDS, your own server). I've tested it most on Supabase, so the examples here are Supabase-flavored.&lt;br&gt;
Point it at a disposable copy of your DB. It reads your policies from the catalog and generates the tests and the seed data:&lt;br&gt;
bashpip install rlsautotest&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# quick check: who can touch what, as an HTML report&lt;/span&gt;
rlsautotest &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; public &lt;span class="nt"&gt;--html&lt;/span&gt; rls-report.html

&lt;span class="c"&gt;# or emit a native pgTAP suite to commit + run in CI&lt;/span&gt;
rlsautotest &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--schema&lt;/span&gt; public &lt;span class="nt"&gt;--emit&lt;/span&gt; supabase/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report reads like a permissions table:&lt;/p&gt;

&lt;p&gt;notes                          SELECT  INSERT  UPDATE  DELETE&lt;br&gt;
service_role                     ✓       ✓       ✓       ✓&lt;br&gt;
authenticated, authorized        ✓       ✓       ✓       ✓&lt;br&gt;
authenticated, not authorized    ·       ·       ·       ·&lt;br&gt;
anon                             ·       ·       ·       ·&lt;br&gt;
You're hunting for one thing: a ✓ where a · should be. An anon or unauthorized user who can act. That's a hole, and it jumps out.&lt;/p&gt;

&lt;p&gt;Two things make it trustworthy:&lt;br&gt;
&lt;strong&gt;Real seed data&lt;/strong&gt;. It works backward from each policy to a row actually owned by the identity, so green means real isolation, not an empty-table pass.&lt;br&gt;
&lt;strong&gt;No false greens&lt;/strong&gt;. Anything it can't verify soundly, it marks instead of faking.&lt;/p&gt;

&lt;p&gt;And --report exits non-zero on a leak or an RLS-off table, so it drops straight into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug that passes code review&lt;/strong&gt;&lt;br&gt;
The kind of thing this catches that humans miss:&lt;br&gt;
A table has &lt;strong&gt;two permissive UPDATE policies&lt;/strong&gt;. Each one's WITH CHECK limits the value a row can become (one status per role). But the check for who may write sits only in USING, not in WITH CHECK.&lt;/p&gt;

&lt;p&gt;Here's the trap:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Postgres OR-combines every permissive policy's WITH CHECK, independent of which USING matched.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the effective check becomes the union of all of them, with the "who" gone. Any identity that can touch the row can write &lt;strong&gt;any value any policy allows&lt;/strong&gt;. A role can set a status its own policy forbids.&lt;br&gt;
Each policy looks correct alone. The leak only exists in the combination. rlsautotest enumerates the value space per identity and shows you exactly which forbidden value slips through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it won't do (on purpose)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It proves your DB enforces what your policies declare, not what you intended. A wrong-but-consistent policy is confirmed, greenly.&lt;br&gt;
A command with no policy shows as blocked and isn't asserted unless you opt in.&lt;br&gt;
A policy behind an opaque function is reported, not faked.&lt;/p&gt;

&lt;p&gt;A tester that emits confident checkmarks it can't back up is worse than no tester. So when it can't prove something, it says so.&lt;br&gt;
Try it&lt;br&gt;
Got policies you're not 100% sure about? Point it at a throwaway copy and read the report. One command. Worst case, you confirm you're fine.&lt;br&gt;
&lt;strong&gt;Repo **+ **docs&lt;/strong&gt;: &lt;a href="https://github.com/unitautogen/rlsautotest#readme" rel="noopener noreferrer"&gt;https://github.com/unitautogen/rlsautotest#readme&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgressql</category>
      <category>rls</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
