<?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: Pon</title>
    <description>The latest articles on DEV Community by Pon (@vollos).</description>
    <link>https://dev.to/vollos</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%2F4003199%2F93d7f013-9802-440b-8148-dff2428836da.jpg</url>
      <title>DEV Community: Pon</title>
      <link>https://dev.to/vollos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vollos"/>
    <language>en</language>
    <item>
      <title>Your Supabase notifications table has RLS on. Anyone signed in can still write into someone else's inbox.</title>
      <dc:creator>Pon</dc:creator>
      <pubDate>Tue, 04 Aug 2026 14:34:38 +0000</pubDate>
      <link>https://dev.to/vollos/your-supabase-notifications-table-has-rls-on-anyone-signed-in-can-still-write-into-someone-elses-4p3c</link>
      <guid>https://dev.to/vollos/your-supabase-notifications-table-has-rls-on-anyone-signed-in-can-still-write-into-someone-elses-4p3c</guid>
      <description>&lt;p&gt;Here's a pattern I keep running into when I look at Supabase apps that shipped a notifications feature: RLS is on, the SELECT policy is correct, everyone tests it, everyone sees only their own notifications, and the feature gets marked done. Nobody checks the other three commands.&lt;/p&gt;

&lt;p&gt;A typical setup looks like this:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own notifications"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&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;recipient_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That policy is fine. It's also usually the only one anyone thinks about, because reading your own notifications is the only thing you personally test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;Somewhere in the build, a feature needs the client to create a notification directly, someone likes a post, follows a user, whatever. The fastest way to make that work from the frontend is an INSERT policy that just lets it through:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"authenticated can insert notifications"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&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;with&lt;/span&gt; &lt;span class="k"&gt;check&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;with check (true)&lt;/code&gt; means any signed-in user can set any &lt;code&gt;recipient_id&lt;/code&gt;, with any title and body they want. The SELECT policy still only shows you your own notifications, so you never notice you can also write everyone else's. You're not scanning your own inbox for messages you didn't send, why would you.&lt;/p&gt;

&lt;p&gt;But anyone holding a valid session token can do exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://YOURPROJECT.supabase.co/rest/v1/notifications"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: YOUR_ANON_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_USER_JWT"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"recipient_id":"&amp;lt;someone-elses-uuid&amp;gt;","title":"Your payment failed","body":"Update your card: evil.example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that succeeds, you've just put a message of your choosing into a stranger's notification feed, from inside your own app's UI, using a phishing hook the recipient has every reason to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this slips past both AI and manual review
&lt;/h2&gt;

&lt;p&gt;The AI wired up exactly what was asked: a client-side path that lets a user action produce a notification. Verifying who the real sender is was never part of the request. Manual testing has the same blind spot. You only ever test your own account, which happens to be the one case this bug doesn't affect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own project in two minutes
&lt;/h2&gt;

&lt;p&gt;List the policies on your notifications table and look at the INSERT row specifically:&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;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'notifications'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the insert policy's &lt;code&gt;with_check&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, blank, or has no link back to an actor/sender column you control server-side, that's the bug sitting right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two ways to close it, in order of how much I'd trust each:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constrain the insert to match the actor:&lt;/strong&gt;&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert notifications you really triggered"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&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;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actor_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This at least ties the row to who really did it, so it's traceable and the client can't spoof &lt;code&gt;actor_id&lt;/code&gt;. It still trusts the client to pick the right &lt;code&gt;recipient_id&lt;/code&gt;, which is weaker than it sounds if any other column drives real behavior later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better: don't let the client write notifications at all.&lt;/strong&gt; Derive them server-side from the event that should trigger them, with a trigger the client can't touch:&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="n"&gt;notify_on_like&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&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;begin&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;values&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;user_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;on_like_notify&lt;/span&gt;
&lt;span class="k"&gt;after&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;likes&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;notify_on_like&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, the client's insert policy on &lt;code&gt;notifications&lt;/code&gt; can just be &lt;code&gt;false&lt;/code&gt;, or not exist at all. The only thing that ever writes there is the trigger, and &lt;code&gt;recipient_id&lt;/code&gt; comes from the real post owner, never from client input.&lt;/p&gt;

&lt;p&gt;I build with AI constantly and this isn't a reason to stop. It's a reason to stop treating "the notifications I see look right" as proof the table is safe, since that test only ever checks the one direction that was never broken. I put together a small tool that checks for this pattern and a few related ones across a repo automatically. If you want me to run it against yours for free, drop a comment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, 13 Aug 2026.&lt;/strong&gt; Mads Hansen pointed out in the comments that the fix above moves the authority into the trigger function and then leaves it unguarded, and he's right. Two things were missing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;security definer&lt;/code&gt; runs the body as the function's owner, and on Supabase that owner usually owns the table too. The RLS bypass comes from that ownership, not from the keyword. With no &lt;code&gt;set search_path&lt;/code&gt;, the caller's path is still in force inside the body, so an unqualified &lt;code&gt;notifications&lt;/code&gt; or &lt;code&gt;posts&lt;/code&gt; resolves to whatever a schema earlier on that path happens to hold. Pin it to empty and qualify every name.&lt;/p&gt;

&lt;p&gt;Mads named the second one too, in his line about deriving actor and recipient from the trusted source row. What it costs to skip: &lt;code&gt;auth.uid()&lt;/code&gt; reads the request JWT, so the moment a like gets inserted from the server with the service role, or straight from SQL, there is no JWT and &lt;code&gt;actor_id&lt;/code&gt; lands as NULL.&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;notify_on_like&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&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;begin&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&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;notifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;values&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&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;posts&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&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="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That version only holds if &lt;code&gt;likes&lt;/code&gt; itself constrains who the liker is, so its insert policy is now load-bearing:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"like as yourself"&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;likes&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&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;with&lt;/span&gt; &lt;span class="k"&gt;check&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mads also suggested a unique key on the event identity so a retry cannot double-post the same notification. Worth having, with the caveat that if you allow unlike and re-like you need the old row gone first, or the second like goes quiet.&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;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;notifications_event_once&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;notifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>This endpoint has auth. The tests are green. It still lets a user hand themselves admin.</title>
      <dc:creator>Pon</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:14:41 +0000</pubDate>
      <link>https://dev.to/vollos/this-endpoint-has-auth-the-tests-are-green-it-still-lets-a-user-hand-themselves-admin-3ham</link>
      <guid>https://dev.to/vollos/this-endpoint-has-auth-the-tests-are-green-it-still-lets-a-user-hand-themselves-admin-3ham</guid>
      <description>&lt;p&gt;Here's a pattern I keep running into in AI-scaffolded update endpoints: the code looks completely ordinary. Nothing about it reads as risky.&lt;/p&gt;

&lt;p&gt;The whole endpoint is one line most people never look twice at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByIdAndUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;&lt;code&gt;requireAuth&lt;/code&gt; checks that someone is logged in. It never checks what they're allowed to send. &lt;code&gt;req.body&lt;/code&gt; goes straight into the update, whatever shape it happens to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;The intended use is updating your own name, bio, or avatar URL. Normal, boring, exactly what AI writes for you in a second when you ask for "let users edit their profile."&lt;/p&gt;

&lt;p&gt;Nothing stops the client from sending fields nobody meant to expose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH https://yourapp.com/api/users/YOUR_OWN_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"Same As Before","role":"admin"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;role&lt;/code&gt; is a column on that user row, that request just promoted the caller to admin. Nothing crashed. The response looks exactly like a normal profile update, because as far as the database is concerned, it was one.&lt;/p&gt;

&lt;p&gt;I shipped a version of this myself, and I didn't catch it right away. Every tool I ran on it was happy — linter clean, tests green, and an AI reviewer signed off. None of them asked what the client was allowed to put in that body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this slips past both AI and manual review
&lt;/h2&gt;

&lt;p&gt;The AI wired up exactly what was asked: read the request body and write it straight into the record. Nobody told it that &lt;code&gt;role&lt;/code&gt;, &lt;code&gt;is_admin&lt;/code&gt;, &lt;code&gt;credits&lt;/code&gt;, or &lt;code&gt;plan_id&lt;/code&gt; are different in kind from &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;bio&lt;/code&gt;. It has no way to know a column is privileged unless something tells it so.&lt;/p&gt;

&lt;p&gt;Manual testing has the same blind spot, because you're the one writing every request by hand. You check that your own update works, and you have no reason to try sending a field you were never supposed to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own project in two minutes
&lt;/h2&gt;

&lt;p&gt;Search your codebase for anywhere a request body gets passed into a write call without being filtered first:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"findByIdAndUpdate(req&lt;/span&gt;&lt;span class="se"&gt;\|\.&lt;/span&gt;&lt;span class="s2"&gt;update(req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="se"&gt;\|\.&lt;/span&gt;&lt;span class="s2"&gt;create(req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;body"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every hit, ask: does the model behind this call have any column a normal user shouldn't be able to set themselves? Role, admin flags, credit balances, subscription tier, ownership fields — anything like that sitting in the same table as the profile fields users are supposed to edit is a mass assignment waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Never pass &lt;code&gt;req.body&lt;/code&gt; into a write call directly. Pick only the fields you meant to allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatarUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByIdAndUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatarUrl&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;Now &lt;code&gt;role&lt;/code&gt; in the request body just gets ignored, no matter who sends it. If you're using a schema validator like Zod, &lt;code&gt;.pick()&lt;/code&gt; or &lt;code&gt;.strict()&lt;/code&gt; on the update shape does the same thing with less to maintain by hand — and it fails loudly on an unexpected field instead of silently dropping it.&lt;/p&gt;

&lt;p&gt;If a privileged field genuinely needs to change through an API, give it its own endpoint with its own authorization check, separate from the one a regular user hits to edit their bio.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit that catches this
&lt;/h2&gt;

&lt;p&gt;Every time a request body flows into a write, it's worth asking one question before shipping: what's allowed in here, and did I decide that on purpose, or did the tool decide it for me? Most of the checks I run don't ask that at all. It's the one I've started asking by hand.&lt;/p&gt;

&lt;p&gt;I put together a small tool that checks a repo for this pattern and a few related ones automatically. If you want me to run it against yours for free, drop a comment.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My public view leaked every user's email. The AI called it "partial user data."</title>
      <dc:creator>Pon</dc:creator>
      <pubDate>Mon, 06 Jul 2026 15:53:36 +0000</pubDate>
      <link>https://dev.to/vollos/my-public-view-leaked-every-users-email-the-ai-called-it-partial-user-data-3ogp</link>
      <guid>https://dev.to/vollos/my-public-view-leaked-every-users-email-the-ai-called-it-partial-user-data-3ogp</guid>
      <description>&lt;p&gt;Every email address in my users table was readable with just the anon key. The app worked fine, tests were green, and it sat like that for about three weeks.&lt;/p&gt;

&lt;p&gt;I was building a side project with Claude earlier this year. At some point I asked for a public profiles page, the kind where visitors can see usernames and avatars without logging in. It did what I asked and created a view:&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;view&lt;/span&gt; &lt;span class="n"&gt;public_profiles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
&lt;span class="k"&gt;select&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;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I remember skimming this and thinking it looked reasonable. I had RLS enabled on &lt;code&gt;users&lt;/code&gt;, policies tested, the whole thing. When I later asked the AI what the view returned, it described it as "partial user data." Which is technically true. It just didn't mention that the partial data included the one column I'd never want public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why RLS didn't save me
&lt;/h2&gt;

&lt;p&gt;Two Postgres behaviors stack up here, and neither one is obvious if you came in through Supabase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Views run with the owner's permissions by default.&lt;/strong&gt; My RLS policies on &lt;code&gt;users&lt;/code&gt; were fine. But a view created by &lt;code&gt;postgres&lt;/code&gt; reads the table as &lt;code&gt;postgres&lt;/code&gt;, and the owner isn't subject to my policies. Reading &lt;code&gt;users&lt;/code&gt; through the view skips RLS entirely, with no warning anywhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Supabase exposes the whole public schema through its API.&lt;/strong&gt; Anything I create in &lt;code&gt;public&lt;/code&gt; gets an endpoint. So the view wasn't sitting unused in the database, it was one HTTP request away for anyone holding the anon key. And the anon key ships in the frontend bundle by design.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So: RLS on, policies correct, and every email still public. Each piece works exactly as documented; together they published a column I never meant to share.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the AI didn't warn me
&lt;/h2&gt;

&lt;p&gt;I don't think the AI did anything wrong by its own lights. I asked for a public profiles page and got a working one. The code ran, the page rendered, my tests (which check what the app shows) all passed. There was no test for "what else can be read that I never render." That question only comes up when someone is thinking like an attacker, and code generation doesn't.&lt;/p&gt;

&lt;p&gt;Most of the security issues I've found in my own AI-built projects follow this shape. Working code that does slightly more than I intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own project in two minutes
&lt;/h2&gt;

&lt;p&gt;List the views in your public schema:&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;viewname&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_views&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each one, look at which columns it exposes:&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="k"&gt;column_name&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public_profiles'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the honest test — ask your API the way a stranger would, with only the anon key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://YOURPROJECT.supabase.co/rest/v1/public_profiles?select=*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: YOUR_ANON_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If emails, phone numbers, or anything else private comes back, you have my three-week problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Pick whichever fits your case:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop the sensitive columns from the view.&lt;/strong&gt; A public profile needs a username and an avatar. It doesn't need an email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Postgres 15+, make the view respect the caller's RLS:&lt;/strong&gt;&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;alter&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;public_profiles&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now reads through the view run as the person asking, and your policies apply again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the view was never meant to be public, revoke API access:&lt;/strong&gt;&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;public_profiles&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;I did the first two. Belt and suspenders felt right after three weeks of not noticing.&lt;/p&gt;

&lt;p&gt;I build with AI every day and that's not changing. But I've stopped treating "it works" as the end of the review, because working code can expose more than it renders. I ended up building a small tool that scans for this pattern and a few related ones automatically. If you're on Supabase and want me to take a look at your repo for free, leave a comment and I'll run it.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped a Supabase app last month and left the front door open for weeks</title>
      <dc:creator>Pon</dc:creator>
      <pubDate>Mon, 29 Jun 2026 13:57:52 +0000</pubDate>
      <link>https://dev.to/vollos/i-shipped-a-supabase-app-last-month-and-left-the-front-door-open-for-weeks-38ee</link>
      <guid>https://dev.to/vollos/i-shipped-a-supabase-app-last-month-and-left-the-front-door-open-for-weeks-38ee</guid>
      <description>&lt;p&gt;I build with AI like everyone else right now. Claude writes most of my backend, I review it, it works, I ship. For a side project I was working on, that loop felt great until I finally sat down and read one of my own RLS policies.&lt;/p&gt;

&lt;p&gt;Here is what I found sitting in my migration:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can view their data"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;profiles&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;using&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that policy name again, then read the &lt;code&gt;using (true)&lt;/code&gt; under it. The name says "users can view their data." The code says "anyone can view everyone's data." Those are not the same thing, and I had shipped the second one.&lt;/p&gt;

&lt;p&gt;If you have ever turned on Row Level Security in Supabase and felt safe, this is the part nobody warns you about. RLS being "enabled" does not mean RLS is doing anything. A policy with &lt;code&gt;using (true)&lt;/code&gt; is a policy that always passes. The lock is on the door, but the rule is "let everybody in."&lt;/p&gt;

&lt;p&gt;I want to be honest about how this happened, because I do not think I am special here. I asked the AI for a policy so users could read their own profiles. It gave me something that ran, the app worked in testing, every row came back fine because I was logged in as myself. Nothing failed. Tests passed. The bug only exists when a different user shows up and reads rows that were never theirs, and that is exactly the case you never test by hand.&lt;/p&gt;

&lt;p&gt;The AI was not wrong in a way I could see. It wrote code that worked. It just did not write code that was safe, because "safe" means thinking about the attacker, and the AI was thinking about the happy path I asked for. That is the gap. It is fast and it is genuinely good, but it does not sit there imagining the user who changes an id in the URL to see if your server stops them.&lt;/p&gt;

&lt;p&gt;So here is how I check now. It takes about two minutes.&lt;/p&gt;

&lt;p&gt;Open the Supabase dashboard, go to the SQL editor, and run this:&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;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;qual&lt;/code&gt; column is the actual USING expression for each policy. Read it. If you see &lt;code&gt;true&lt;/code&gt; sitting in there on a SELECT policy for a table that holds user data, that row is readable by anyone who can hit your API. Same story for &lt;code&gt;with check&lt;/code&gt; on insert and update policies.&lt;/p&gt;

&lt;p&gt;What you usually want instead is the policy tied to the logged-in user, something like:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can view their own profile"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;profiles&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;using&lt;/span&gt; &lt;span class="p"&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the rule says what the name always claimed: a user only sees rows where the user id matches theirs. Swap &lt;code&gt;user_id&lt;/code&gt; for whatever your column is.&lt;/p&gt;

&lt;p&gt;A few things I do every time now, none of them clever:&lt;/p&gt;

&lt;p&gt;Read the &lt;code&gt;qual&lt;/code&gt;, not the policy name. The name is a comment. It can say anything. The &lt;code&gt;qual&lt;/code&gt; is the law.&lt;/p&gt;

&lt;p&gt;Check every table that holds something personal. Not the obvious one alone — all of them. I had three tables and only thought about one.&lt;/p&gt;

&lt;p&gt;Log in as a second test user and try to read the first user's rows. If you get data back, you found it before someone else did.&lt;/p&gt;

&lt;p&gt;I am not writing this to scare anyone off AI. I use it every day and I am not going back. I just had to learn that the speed it gives me on building is speed I have to spend back on checking, because it will hand me something that runs and let me believe it is finished.&lt;/p&gt;

&lt;p&gt;I kept finding this same &lt;code&gt;using (true)&lt;/code&gt; pattern in my own projects often enough that I started writing a small thing to scan for it automatically, along with a couple of other Supabase footguns like public views that leak email and policies granted to the anon role. If you have an AI-built Supabase app and want me to run it over your schema for free, drop a comment. I am still testing it and I would rather find these on a friendly repo than have you find them the hard way.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
