<?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: basildraz-arch</title>
    <description>The latest articles on DEV Community by basildraz-arch (@basildrazarch).</description>
    <link>https://dev.to/basildrazarch</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%2F4110195%2F6909da12-953b-4128-be1f-32c642c7d39f.png</url>
      <title>DEV Community: basildraz-arch</title>
      <link>https://dev.to/basildrazarch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/basildrazarch"/>
    <language>en</language>
    <item>
      <title>Six ways I leaked data through correct RLS policies</title>
      <dc:creator>basildraz-arch</dc:creator>
      <pubDate>Fri, 04 Sep 2026 17:41:38 +0000</pubDate>
      <link>https://dev.to/basildrazarch/six-ways-i-leaked-data-through-correct-rls-policies-3l39</link>
      <guid>https://dev.to/basildrazarch/six-ways-i-leaked-data-through-correct-rls-policies-3l39</guid>
      <description>&lt;p&gt;I build a point-of-sale system for retail shops. Multi-tenant, Supabase, Postgres, ninety-odd migrations, real money going through it every day. Over about six weeks I ran three security reviews on it.&lt;/p&gt;

&lt;p&gt;Every hole I found got past policies that were, as written, correct.&lt;/p&gt;

&lt;p&gt;That is the part nobody tells you when you turn on Row Level Security and feel safe. RLS is one layer. Underneath it sits the Postgres privilege system, and above it sits PostgREST, and the leaks live in the seams between them. Here are the six that cost me real time, each with the fix and the way to prove the fix landed.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Revoking from &lt;code&gt;anon&lt;/code&gt; does nothing
&lt;/h2&gt;

&lt;p&gt;I wrote this and moved on:&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;revoke&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="n"&gt;my_function&lt;/span&gt;&lt;span class="p"&gt;()&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;anon&lt;/code&gt; could still call it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create function&lt;/code&gt; grants &lt;code&gt;EXECUTE&lt;/code&gt; to &lt;code&gt;PUBLIC&lt;/code&gt; by default, and &lt;code&gt;anon&lt;/code&gt; inherits from &lt;code&gt;PUBLIC&lt;/code&gt;. Revoking the role's own grant leaves the inherited one untouched, and Postgres does not warn you.&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;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="n"&gt;my_function&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="n"&gt;my_function&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;Trigger functions are not exempt from the default grant either. They also do not need &lt;code&gt;EXECUTE&lt;/code&gt; to fire — Postgres runs a trigger as part of the table operation, not on behalf of the calling role — so revoking costs you nothing. I found three exposed trigger functions in one review, and then, in the &lt;em&gt;next&lt;/em&gt; review, found two more that I had created myself while fixing the first three.&lt;/p&gt;

&lt;p&gt;Any &lt;code&gt;create function&lt;/code&gt; should end with a revoke.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Column-level revokes fail silently under a table-level grant
&lt;/h2&gt;

&lt;p&gt;The purchase price of an item is the number my whole product exists to protect. A cashier must never see it. So:&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;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buy_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;items&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error. No effect. The role still held &lt;code&gt;SELECT&lt;/code&gt; on the whole table, and a table-wide grant subsumes column grants.&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;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;items&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sell_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;items&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;This has a cost you should accept knowingly: &lt;code&gt;select *&lt;/code&gt; on that table now fails. Every read has to name its columns, and every new column has to be added to the grant &lt;em&gt;and&lt;/em&gt; to the query. That is a real maintenance burden, and it is the correct trade.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. New columns are deny-by-default for reads and allow-by-default for writes
&lt;/h2&gt;

&lt;p&gt;This asymmetry is the one I would most like to have known a year ago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new column + SELECT  -&amp;gt;  denied  until you grant
new column + UPDATE  -&amp;gt;  allowed until you revoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had locked down reads on a table column by column. Writes still carried a table-level &lt;code&gt;GRANT UPDATE&lt;/code&gt;. Months later I added an invoice counter column to that table, granted it nothing, and assumed nothing meant nothing.&lt;/p&gt;

&lt;p&gt;The browser could set it. Tested with a real user account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;counter before = 54    after = 1    response = no error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next invoice would have been number 1 again — a duplicate in a tax invoice book, written silently. I closed it with a trigger and a unique index on &lt;code&gt;(business_id, invoice_number)&lt;/code&gt; as a second belt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every new column needs two questions, not one: who can read it (grants), and who can write it (grants and triggers).&lt;/strong&gt; They fail in opposite directions.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. A &lt;code&gt;SECURITY DEFINER&lt;/code&gt; view that bypasses RLS to read, bypasses it to write
&lt;/h2&gt;

&lt;p&gt;I needed one screen to read a column that RLS forbade, so I made a view. In Postgres 15+, a view runs with its owner's rights unless you create it with &lt;code&gt;security_invoker = true&lt;/code&gt;, so the view saw what the caller could not. That was the intent.&lt;/p&gt;

&lt;p&gt;What I did not think about: a simple &lt;code&gt;select&lt;/code&gt; over a single table is &lt;strong&gt;automatically updatable&lt;/strong&gt;, and PostgREST exposes &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on it like any table.&lt;/p&gt;

&lt;p&gt;Tested with a real employee account:&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;delete&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;       &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;RLS&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;item_costs&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt;     &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;did&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An employee could delete any product in the shop through a view I built to &lt;em&gt;read&lt;/em&gt; a price.&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;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="n"&gt;item_costs&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="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;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;item_costs&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;What partly saved me was a column-protection trigger that refused a tenant-id change coming through the view. &lt;strong&gt;Triggers fire even when the write arrives through a view.&lt;/strong&gt; Deletes had no trigger, which is exactly why that one got through. If you rely on triggers as a backstop, cover delete too.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. &lt;code&gt;auth.role()&lt;/code&gt; cannot tell you where a write came from
&lt;/h2&gt;

&lt;p&gt;I wrote a trigger to stop the browser tampering with a counter, and guarded it with &lt;code&gt;auth.role() = 'authenticated'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It would have blocked every sale in the application.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;auth.role()&lt;/code&gt; reads the JWT, and the JWT does not change when you enter a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function. Inside my own reviewed, trusted &lt;code&gt;record_sale()&lt;/code&gt;, it still said &lt;code&gt;authenticated&lt;/code&gt; — so the trigger blocked the legitimate path along with the illegitimate one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;current_user&lt;/code&gt; is the one that knows. PostgREST runs as &lt;code&gt;authenticated&lt;/code&gt;; inside a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function owned by &lt;code&gt;postgres&lt;/code&gt;, it is &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;auth.role()&lt;/code&gt; tells you &lt;strong&gt;who the user is&lt;/strong&gt;.&lt;br&gt;
&lt;code&gt;current_user&lt;/code&gt; tells you &lt;strong&gt;where the write is coming from&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A trigger that distinguishes "the browser" from "a function I reviewed" needs the second.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. RLS answers the question you asked, not the one you meant
&lt;/h2&gt;

&lt;p&gt;Two products in a customer's shop had stock that did not match the sum of their batches. The extra batches carried &lt;em&gt;my&lt;/em&gt; tenant id, on &lt;em&gt;their&lt;/em&gt; products.&lt;/p&gt;

&lt;p&gt;I had an admin role that could read across tenants. I opened one of their products, adjusted a quantity, and my code wrote a stock batch stamped with my own tenant id onto a product that was not mine.&lt;/p&gt;

&lt;p&gt;RLS did not stop it, and it was right not to. The policy asked: &lt;em&gt;does this batch belong to your tenant?&lt;/em&gt; Yes, it did. &lt;strong&gt;Nobody asked whether the product it points at belongs to your tenant too.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any row that joins two tables needs a policy that asks about &lt;strong&gt;both&lt;/strong&gt;, not about the row.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I added a trigger asserting that batch, product and warehouse all share a tenant.&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually finds these
&lt;/h2&gt;

&lt;p&gt;Reading policies did not find any of the six. Three things did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query the catalog.&lt;/strong&gt; Most of the above leaves a fingerprint in &lt;code&gt;pg_proc&lt;/code&gt;, &lt;code&gt;pg_policies&lt;/code&gt;, &lt;code&gt;pg_class&lt;/code&gt; or &lt;code&gt;information_schema.role_table_grants&lt;/code&gt;. I put the checks in one read-only script and run it after every migration. It has caught a function left behind by an ad-hoc fix in the SQL editor — &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, no &lt;code&gt;search_path&lt;/code&gt;, no auth check at all, callable by &lt;code&gt;anon&lt;/code&gt;, writing to a cost column. Unused by any code. Sitting there for months. The only reason it was never exploited is that it referenced a table that did not exist, so it errored before it reached the update.&lt;/p&gt;

&lt;p&gt;Anything created in the SQL editor outside your migrations gets no review, appears in no diff, and stays forever. Delete your scratch work in the same session you create it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be the user.&lt;/strong&gt; Set the JWT claims to a real low-privilege account, then &lt;code&gt;count(*)&lt;/code&gt; every table in the schema and look at everything that returned rows:&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;select&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"sub":"&amp;lt;user auth uid&amp;gt;","role":"authenticated"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&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;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- then loop pg_class and count each table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two minutes of that found two things reading the code had missed: the shop owner's email address and a pending PIN, both visible to a cashier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Believe the database, not the migration.&lt;/strong&gt; The worst hour I lost came from assuming a door was shut because the application had stopped knocking on it. The code no longer called a function; the function still had &lt;code&gt;grant execute ... to authenticated&lt;/code&gt; and still returned cost data to anyone who asked it directly. Worse, calling it decremented stock with no sale attached — an employee could walk goods out and the count would simply be short.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Code that stopped using a door did not close it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After every revoke, ask:&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;select&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.my_func()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"I ran the command" is not evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;The catalog checks are in one file you can paste into the Supabase SQL editor. It is read-only — it does not modify a row — and any count above zero is worth a look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/basildraz-arch/supabase-rls-audit/blob/main/supabase-rls-audit.sql" rel="noopener noreferrer"&gt;supabase-rls-audit.sql&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ran it against my own database while writing this. The four checks that must be zero were zero — the work above held. It still found two tables where reads are restricted column by column while writes are granted on the whole table: the exact asymmetry from #3, sitting there waiting for the next column I add.&lt;/p&gt;

&lt;p&gt;That is the honest use of a tool like this. It does not tell you that you are safe. It tells you where the next mistake is going to come from.&lt;/p&gt;

&lt;p&gt;If you run it and something comes back non-zero that you cannot explain, open an issue — I am happy to look at the output with you. That is what I do.&lt;/p&gt;

&lt;p&gt;The script and this write-up both live here: &lt;strong&gt;&lt;a href="https://github.com/basildraz-arch/supabase-rls-audit" rel="noopener noreferrer"&gt;github.com/basildraz-arch/supabase-rls-audit&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

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