<?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: Peter Panzer</title>
    <description>The latest articles on DEV Community by Peter Panzer (@peter_panzer_733381724c86).</description>
    <link>https://dev.to/peter_panzer_733381724c86</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%2F2710740%2Fb69f645e-8413-4ef2-8741-a90e8a26f23b.jpg</url>
      <title>DEV Community: Peter Panzer</title>
      <link>https://dev.to/peter_panzer_733381724c86</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/peter_panzer_733381724c86"/>
    <language>en</language>
    <item>
      <title>My database is the referee: a fair exchange enforced by Supabase RLS</title>
      <dc:creator>Peter Panzer</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:15:57 +0000</pubDate>
      <link>https://dev.to/peter_panzer_733381724c86/my-database-is-the-referee-a-fair-exchange-enforced-by-supabase-rls-4bcf</link>
      <guid>https://dev.to/peter_panzer_733381724c86/my-database-is-the-referee-a-fair-exchange-enforced-by-supabase-rls-4bcf</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://github.com/PanzerPeter/Nearside" rel="noopener noreferrer"&gt;Nearside&lt;/a&gt;, an end-to-end encrypted messenger on Supabase. The server never holds a readable message body. Every row is ciphertext sealed on the device, and the keys never leave the phone.&lt;/p&gt;

&lt;p&gt;One feature needed something the encryption couldn't give me. I call it a sealed exchange. You ask a question and answer it yourself at the same time. Your friend answers too, and neither of you can read the other's answer until both answers exist. Think "what should we name the dog", where you don't want the second person copying the first.&lt;/p&gt;

&lt;p&gt;Below is how the database enforces that, a bug I shipped in the first version, and the two scripts I use to check that the live project actually runs the SQL in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the app can't be the one checking
&lt;/h2&gt;

&lt;p&gt;The obvious version is a check in the client: don't show the other answer until I've answered. But the repo is public, and the Supabase URL and anon key ship inside the app. Anyone can delete that check, or skip the app and query the table directly with their own session token. A client-side rule in an open-source app is a suggestion.&lt;/p&gt;

&lt;p&gt;A fair exchange between two people who don't trust each other needs a referee. Whoever reads second can always read and walk away. So the database is the referee. It holds two ciphertexts it can't open, and it decides one thing: when each side gets handed the other's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;prompt_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ciphertext&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;nonce&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;sealed_answers_one_each&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question itself is an ordinary message row with a &lt;code&gt;sealed_prompt&lt;/code&gt; flag. Both people can read the question right away, which is what makes it a question and not a puzzle. The unique constraint means one answer each. Without it, someone could stack several answers, and "which one counts" has no good answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in one policy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"sealed_answers_select_after_own"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_answered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can always see your own answer, because the app needs it to render your side while you wait. You can see anyone else's only once you've committed one of your own. Someone outside the conversation can't insert an answer (the INSERT policy checks they're one of the two participants), so they never get past the second branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recursion trap
&lt;/h2&gt;

&lt;p&gt;The obvious way to write the second branch is inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sealed_answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres rejects this with &lt;code&gt;infinite recursion detected in policy for relation "sealed_answers"&lt;/code&gt;. Evaluating the policy runs the subquery, the subquery reads &lt;code&gt;sealed_answers&lt;/code&gt;, and reading &lt;code&gt;sealed_answers&lt;/code&gt; evaluates the policy.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function. It runs as its owner, so RLS doesn't apply inside it, and the loop ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_answered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;who&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;who&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;who&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_answered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_answered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SET search_path = ''&lt;/code&gt; matters for any definer function. Without it, a caller who can create objects in a schema on the search path can shadow the tables the function reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I shipped
&lt;/h2&gt;

&lt;p&gt;Look at the first line of that function body: &lt;code&gt;who = (SELECT auth.uid()) AND&lt;/code&gt;. The first version didn't have it.&lt;/p&gt;

&lt;p&gt;The policy only ever asks about the caller. But the function lives in &lt;code&gt;public&lt;/code&gt;, and PostgREST exposes every function in an exposed schema that the role can execute. So &lt;code&gt;has_answered&lt;/code&gt; was also an endpoint at &lt;code&gt;/rest/v1/rpc/has_answered&lt;/code&gt;, and any signed-in user could ask it about any prompt and any user id. Put someone else's id in the &lt;code&gt;who&lt;/code&gt; slot and you get back "has this person answered yet?", which is exactly the fact the policy exists to hold back.&lt;/p&gt;

&lt;p&gt;Nothing broke and no test failed. It came up in a later security pass, and the fix was to make the function answer only about whoever is calling it. What I took away from it: &lt;strong&gt;every &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function in &lt;code&gt;public&lt;/code&gt; is a public API.&lt;/strong&gt; Write it to answer exactly the question your policy asks and nothing wider, or put it in a schema PostgREST doesn't expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  No UPDATE, no DELETE, and no grant either
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sealed_answers&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immutability is half the protocol. If you can edit your answer after the reveal, it wasn't committed before it. If you can delete it, the second person can read and then take their half back.&lt;/p&gt;

&lt;p&gt;With RLS on and no UPDATE policy, updates are already denied. I revoke the grant as well, so there are two locks. If someone adds a permissive policy by accident a year from now, the missing grant still says no. Answers do disappear when their question is deleted, because a cascade runs as the table owner and isn't subject to RLS.&lt;/p&gt;

&lt;p&gt;Asking a question takes two inserts (the question and the asker's answer), and between them there's a moment where the question exists unanswered. The other person could answer into that gap and unlock nothing. So asking goes through one function that does both inserts in a single transaction. That function is &lt;code&gt;SECURITY INVOKER&lt;/code&gt; on purpose, so every policy, the rate limit and the expiry trigger still apply to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't protect against
&lt;/h2&gt;

&lt;p&gt;Two limits, so nobody has to find them in the comments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can answer with nonsense to force the reveal. What makes that costly is that the nonsense is permanent and sits in the thread under your name.&lt;/li&gt;
&lt;li&gt;The table owner and the &lt;code&gt;service_role&lt;/code&gt; key bypass RLS. Whoever runs the server could hand out answers in the wrong order. They still can't read them, because all they ever get is ciphertext.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other half: is the live database the one in the repo?
&lt;/h2&gt;

&lt;p&gt;All of this is only as good as the SQL actually running on the live project. I apply migrations by hand in the Supabase SQL editor, which means "I ran it" is a memory, not a fact.&lt;/p&gt;

&lt;p&gt;The repo keeps two descriptions of the database: a folder of migrations (the history) and a single &lt;code&gt;schema.sql&lt;/code&gt; (the current shape). Two scripts keep them honest.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm run db:verify&lt;/code&gt; starts a throwaway Postgres in Docker, builds one database by replaying every migration in order and another from &lt;code&gt;schema.sql&lt;/code&gt;, then fingerprints both catalogs and diffs them. The fingerprint covers tables and columns, constraints, policies, table and function grants, storage bucket settings, and an md5 of every function body. If I change one file and forget the other, the diff isn't empty.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm run db:audit&lt;/code&gt; runs the same fingerprint query against the live project and diffs it against &lt;code&gt;schema.sql&lt;/code&gt;. It only runs catalog &lt;code&gt;SELECT&lt;/code&gt;s and writes nothing.&lt;/p&gt;

&lt;p&gt;The first time I ran the audit, it found two things my notes had wrong. One migration was written down as applied and had never run. Two others had been pasted in the same sitting, and only the second one took. The worst finding was a function that existed on the live project and was granted, but still had the previous migration's body. Postgres doesn't resolve a plpgsql body until you call it, so nothing errors until a user taps the button.&lt;/p&gt;

&lt;p&gt;It has also raised a false alarm. Two functions came back as "wrong body", and when I pulled them from the live database they were character for character the repo's code, minus the &lt;code&gt;--&lt;/code&gt; comments. Somewhere between my editor and Postgres the comments had been stripped. Comments don't run, so the fingerprint now hashes function bodies with comments removed and whitespace collapsed. An audit that cries wolf over text that never executes is one you stop reading.&lt;/p&gt;

&lt;p&gt;One small trap from writing it: I compare the two sorted fingerprints with &lt;code&gt;comm&lt;/code&gt;. If &lt;code&gt;sort&lt;/code&gt; and &lt;code&gt;comm&lt;/code&gt; disagree on collation, &lt;code&gt;comm&lt;/code&gt; decides its input isn't sorted and quietly compares nothing, which looks exactly like a clean audit. &lt;code&gt;LC_ALL=C&lt;/code&gt; on both fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Everything is in the repo, with the reasoning in the SQL comments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the policy, the function and the grants: &lt;a href="https://github.com/PanzerPeter/Nearside/blob/main/supabase/schema.sql" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/schema.sql&lt;/code&gt;&lt;/a&gt; (section 5c)&lt;/li&gt;
&lt;li&gt;the replay diff: &lt;a href="https://github.com/PanzerPeter/Nearside/blob/main/supabase/verify/verify.sh" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/verify/verify.sh&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;the live audit: &lt;a href="https://github.com/PanzerPeter/Nearside/blob/main/supabase/verify/live-audit.sh" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/verify/live-audit.sh&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you see a way around the policy, I want to hear it. Open an issue or leave a comment here.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
