<?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: Life Admin Pilot</title>
    <description>The latest articles on DEV Community by Life Admin Pilot (@life_admin_pilot).</description>
    <link>https://dev.to/life_admin_pilot</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%2F4217357%2F561b23a3-986d-4d96-bb40-644a0fbb6003.png</url>
      <title>DEV Community: Life Admin Pilot</title>
      <link>https://dev.to/life_admin_pilot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/life_admin_pilot"/>
    <language>en</language>
    <item>
      <title>auth.uid() is null: the Supabase RLS policy that lets signed-out visitors read everything</title>
      <dc:creator>Life Admin Pilot</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:20:28 +0000</pubDate>
      <link>https://dev.to/life_admin_pilot/authuid-is-null-the-supabase-rls-policy-that-lets-signed-out-visitors-read-everything-dl1</link>
      <guid>https://dev.to/life_admin_pilot/authuid-is-null-the-supabase-rls-policy-that-lets-signed-out-visitors-read-everything-dl1</guid>
      <description>&lt;p&gt;This policy looks like it protects each user's rows. It actually hands every row to anyone who isn't signed in.&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;"todos access"&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;todos&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&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="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&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;Read it the way Postgres does. For a signed-in user, &lt;code&gt;auth.uid()&lt;/code&gt; is their ID, so the first half is false and the second half limits them to their own rows. So far, so good.&lt;/p&gt;

&lt;p&gt;For a signed-out request, &lt;code&gt;auth.uid()&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. The first half is true, and &lt;code&gt;true or anything&lt;/code&gt; is true, for every row. Because this is a &lt;code&gt;for all&lt;/code&gt; policy, a signed-out visitor can also update and delete every row.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the attack looks like
&lt;/h2&gt;

&lt;p&gt;No hacking skills are needed. Your project URL and anon key are inside your app's JavaScript, and anyone can read them in the browser's developer tools. Then:&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="s1"&gt;'https://YOUR-PROJECT.supabase.co/rest/v1/todos?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;That request carries no user, so &lt;code&gt;auth.uid()&lt;/code&gt; is null and the response is the whole table. We reproduced this on a test Postgres database set up like Supabase: the signed-out request returned another user's private todo.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it ends up in your app
&lt;/h2&gt;

&lt;p&gt;Nobody writes this on purpose to leak data. It usually appears when something didn't work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app showed a "permission denied" or an empty list before login was built, and the quickest fix that made the error go away was letting null users through.&lt;/li&gt;
&lt;li&gt;A landing page needed to show some data to visitors, and the policy was widened instead of adding a separate public rule.&lt;/li&gt;
&lt;li&gt;An AI coding tool was asked to "fix the RLS error" and loosened the policy rather than fixing the request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same hole has a few spellings. Watch for all of them:&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="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;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&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="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;      &lt;span class="c1"&gt;-- the "fast" style Supabase recommends&lt;/span&gt;
&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why security scans don't catch it
&lt;/h2&gt;

&lt;p&gt;Supabase's Security Advisor (the database linter, which Lovable's security scan builds on) checks for patterns such as RLS being turned off, or a policy that is literally &lt;code&gt;true&lt;/code&gt;. This policy is neither. It has a real owner check in it, so it passes.&lt;/p&gt;

&lt;p&gt;The Supabase team knows about the gap. A &lt;a href="https://github.com/supabase/splinter/issues/165" rel="noopener noreferrer"&gt;proposal to detect it&lt;/a&gt; was opened in June 2026, with a &lt;a href="https://github.com/supabase/splinter/pull/169" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; adding the lint. As of late October 2026 both are still open, so the Advisor in your dashboard does not flag it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your project in one query
&lt;/h2&gt;

&lt;p&gt;Run this in the Supabase SQL Editor. It reads only your policy definitions:&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;cmd&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;concat_ws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;~*&lt;/span&gt; &lt;span class="s1"&gt;'auth&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s1"&gt;( as uid&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s1"&gt;)? is null'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any row it returns is a policy that lets signed-out visitors in.&lt;/p&gt;

&lt;p&gt;To check this together with the other common mistakes (users who can make themselves admin, private storage buckets anyone can list, "any signed-in user" policies), I built &lt;a href="https://rls.lifeadminpilot.com/" rel="noopener noreferrer"&gt;RLS Check&lt;/a&gt;. It's free and runs in your browser; the query result you paste never leaves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to fix it
&lt;/h2&gt;

&lt;p&gt;Remove the &lt;code&gt;is null&lt;/code&gt; branch so only the owner gets in:&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;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"todos access"&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;todos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"owner can manage own todos"&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;todos&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&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="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="o"&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="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;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="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;If signed-out visitors really do need to see some rows, say so explicitly with a separate read-only policy, for example on a column like &lt;code&gt;is_public&lt;/code&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;"anyone can read public todos"&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;todos&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;anon&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;is_public&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the &lt;code&gt;curl&lt;/code&gt; request above again. Without a signed-in user it should return &lt;code&gt;[]&lt;/code&gt; (or only the public rows), and the owner should still see their own data in the app.&lt;/p&gt;

&lt;p&gt;Using Lovable, Bolt or Cursor? Paste this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;My Supabase policy "todos access" on public.todos uses "auth.uid() is null or user_id = auth.uid()", which lets signed-out visitors read, change and delete every row. Write a migration that replaces it with a policy for the authenticated role using (select auth.uid()) = user_id for both using and with check. Show me the SQL before applying it, then confirm a signed-out request returns no rows and the owner still sees theirs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rls.lifeadminpilot.com/guides/auth-uid-is-null-rls-bypass" rel="noopener noreferrer"&gt;rls.lifeadminpilot.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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