<?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: Claudio Ibe</title>
    <description>The latest articles on DEV Community by Claudio Ibe (@0xclaudi0).</description>
    <link>https://dev.to/0xclaudi0</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%2F1376158%2Fb9f29324-e15e-4739-b462-a50f2448c1d7.jpg</url>
      <title>DEV Community: Claudio Ibe</title>
      <link>https://dev.to/0xclaudi0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0xclaudi0"/>
    <language>en</language>
    <item>
      <title>Row Level Security, explained how i would've wanted it starting out :) .</title>
      <dc:creator>Claudio Ibe</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:27:52 +0000</pubDate>
      <link>https://dev.to/0xclaudi0/row-level-security-explained-how-i-wouldve-wanted-it-starting-out--2kgh</link>
      <guid>https://dev.to/0xclaudi0/row-level-security-explained-how-i-wouldve-wanted-it-starting-out--2kgh</guid>
      <description>&lt;p&gt;Most people meet row level security the same way. You enable it because a&lt;br&gt;
tutorial told you to, deploy, and your app returns an empty array. No error, no&lt;br&gt;
stack trace, just &lt;code&gt;[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The opposite failure is worse. Everything works, ships, and six weeks later you&lt;br&gt;
find out any signed-in user could read every other user's rows the whole time.&lt;/p&gt;

&lt;p&gt;Both come from the same gap. RLS is a small idea with a few sharp edges, and the&lt;br&gt;
edges are where people lose their afternoons.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a policy actually is
&lt;/h2&gt;

&lt;p&gt;Row level security turns a table into a filtered view of itself, per request.&lt;/p&gt;

&lt;p&gt;You write a boolean expression. Postgres runs it against every row a query&lt;br&gt;
touches, and any row where the expression is not true does not appear in the&lt;br&gt;
result. It does not count toward &lt;code&gt;count(*)&lt;/code&gt; either, and the client cannot tell&lt;br&gt;
the difference between a row it may not see and a row that was never written.&lt;/p&gt;

&lt;p&gt;That last part is why RLS is worth the trouble. The filtering happens inside the&lt;br&gt;
database, below your API, below your ORM, below whatever you forgot to put a&lt;br&gt;
&lt;code&gt;where&lt;/code&gt; clause 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;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;own_notes&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&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;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;Read it as a sentence. For authenticated users running a select on &lt;code&gt;notes&lt;/code&gt;, a&lt;br&gt;
row is visible when its &lt;code&gt;user_id&lt;/code&gt; matches the caller's ID.&lt;/p&gt;
&lt;h2&gt;
  
  
  Enabling RLS denies everything
&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run that on a table with no policies and every read returns nothing. For&lt;br&gt;
everyone. Forever.&lt;/p&gt;

&lt;p&gt;This is deliberate. RLS is default-deny, so enabling it on a table you have not&lt;br&gt;
written policies for fails closed rather than open. Given the alternative, that&lt;br&gt;
is the right choice.&lt;/p&gt;

&lt;p&gt;What makes it painful is the silence. Postgres raises no error, because nothing&lt;br&gt;
went wrong by its reckoning. You asked for rows you are allowed to see, and&lt;br&gt;
there are none. Your API returns &lt;code&gt;200 OK&lt;/code&gt; and an empty list, and you go looking&lt;br&gt;
for a bug in your frontend.&lt;/p&gt;

&lt;p&gt;If a query started returning nothing the moment you touched RLS, you do not have&lt;br&gt;
a bug. You have a table with no policies.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other permission system
&lt;/h2&gt;

&lt;p&gt;This one costs people days.&lt;/p&gt;

&lt;p&gt;RLS is not the only thing standing between a role and a table. Before Postgres&lt;br&gt;
consults a single policy, it checks whether the role has privileges on the table&lt;br&gt;
at all, using the ordinary &lt;code&gt;GRANT&lt;/code&gt; system that predates RLS by decades.&lt;/p&gt;

&lt;p&gt;If the grant is missing, the query fails outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: permission denied for table notes (SQLSTATE 42501)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your policies never ran. Postgres stopped before it reached them, so every&lt;br&gt;
minute you spend rewriting them is wasted and the fix is a &lt;code&gt;grant&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;This bites Supabase users specifically, because migrations run as the &lt;code&gt;postgres&lt;/code&gt;&lt;br&gt;
role and its default privileges do not include DML for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt;&lt;br&gt;
or &lt;code&gt;service_role&lt;/code&gt;. A freshly created table hands those roles &lt;code&gt;REFERENCES&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;TRIGGER&lt;/code&gt; and &lt;code&gt;TRUNCATE&lt;/code&gt;, and nothing else.&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;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;notes&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&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;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&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;on&lt;/span&gt; &lt;span class="n"&gt;notes&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;The tell is in the message. &lt;code&gt;42501&lt;/code&gt; covers both failures, and the wording&lt;br&gt;
distinguishes them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message contains&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;What to fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;permission denied for table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing grant, RLS never ran&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;grant&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new row violates row-level security policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grant is fine, a policy refused the row&lt;/td&gt;
&lt;td&gt;the policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the message, not just the code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where identity comes from
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; looks like it knows who you are. It reads a claim out of the token&lt;br&gt;
attached to the current request, and that is the whole mechanism.&lt;/p&gt;

&lt;p&gt;Supabase sets a Postgres configuration parameter, &lt;code&gt;request.jwt.claims&lt;/code&gt;, from the&lt;br&gt;
verified JWT. &lt;code&gt;auth.uid()&lt;/code&gt; pulls &lt;code&gt;sub&lt;/code&gt; out of that JSON and casts it to a UUID.&lt;/p&gt;

&lt;p&gt;So when there is no token, &lt;code&gt;auth.uid()&lt;/code&gt; returns NULL. A policy of&lt;br&gt;
&lt;code&gt;auth.uid() = user_id&lt;/code&gt; becomes &lt;code&gt;NULL = user_id&lt;/code&gt;, which evaluates to NULL, which&lt;br&gt;
is not true, so the row is denied.&lt;/p&gt;

&lt;p&gt;That produces the most confusing bug in the system, where the owner of a row&lt;br&gt;
cannot see their own row. The policy is correct. The grants are correct. The&lt;br&gt;
data is correct. The request arrived without a valid session, so as far as&lt;br&gt;
Postgres is concerned nobody is asking.&lt;/p&gt;

&lt;p&gt;In practice that means an expired token, or a client sending the anon key with&lt;br&gt;
no &lt;code&gt;Authorization&lt;/code&gt; header. The user looks signed in, because your frontend still&lt;br&gt;
has their profile in memory. Postgres disagrees.&lt;/p&gt;

&lt;p&gt;The role and the token are also separate things. A request can arrive as the&lt;br&gt;
&lt;code&gt;authenticated&lt;/code&gt; role carrying no claims at all: same role, no identity.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading is not writing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;using&lt;/code&gt; and &lt;code&gt;with check&lt;/code&gt; answer different questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;using&lt;/code&gt;&lt;/strong&gt; filters rows that already exist. It applies to &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;
and &lt;code&gt;delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;with check&lt;/code&gt;&lt;/strong&gt; validates rows on their way in. It applies to &lt;code&gt;insert&lt;/code&gt; and
&lt;code&gt;update&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An update touches both. &lt;code&gt;using&lt;/code&gt; decides which rows you may modify, and&lt;br&gt;
&lt;code&gt;with check&lt;/code&gt; decides what they are allowed to look like afterwards.&lt;/p&gt;

&lt;p&gt;Leave &lt;code&gt;with check&lt;/code&gt; out of an update policy and Postgres applies the &lt;code&gt;using&lt;/code&gt;&lt;br&gt;
expression to the new row as well. So this policy, with no &lt;code&gt;with check&lt;/code&gt; at all:&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="n"&gt;own_notes&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&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;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;already stops a user handing their row to somebody else. The modified row would&lt;br&gt;
carry a different &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;using&lt;/code&gt; is tested against it, and the statement&lt;br&gt;
fails with &lt;code&gt;new row violates row-level security policy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That fallback is a safe default, and it is worth knowing about, because it means&lt;br&gt;
an update policy is stricter than it looks. Write an explicit &lt;code&gt;with check&lt;/code&gt; when&lt;br&gt;
the rule for what a row may become genuinely differs from the rule for which&lt;br&gt;
rows you may touch.&lt;/p&gt;

&lt;p&gt;A policy declared &lt;code&gt;for all&lt;/code&gt; applies one expression to every command, which is&lt;br&gt;
convenient and occasionally too blunt. Stopping a soft-deleted row from being&lt;br&gt;
read while still letting its owner soft-delete it takes two policies with&lt;br&gt;
different expressions.&lt;/p&gt;
&lt;h2&gt;
  
  
  How policies combine
&lt;/h2&gt;

&lt;p&gt;Multiple policies on a table combine, and the rules are worth memorising:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permissive&lt;/strong&gt; policies, the default, are OR-ed together. Adding one can only
widen access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrictive&lt;/strong&gt; policies are AND-ed on top. Adding one can only narrow access.&lt;/li&gt;
&lt;li&gt;A row must pass at least one permissive policy and every restrictive policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third rule is where people get hurt. A restrictive policy cannot grant&lt;br&gt;
anything. If a table has restrictive policies and no permissive one that applies&lt;br&gt;
to you, every row is denied, however cleanly the restrictive expressions pass.&lt;br&gt;
"I added a restrictive policy and now nothing works" is correct, documented&lt;br&gt;
behaviour.&lt;/p&gt;

&lt;p&gt;There is a quieter version of the same trap. A policy only applies to the roles&lt;br&gt;
it names:&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="n"&gt;read_published&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;articles&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="c1"&gt;-- signed-out visitors are `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;published&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a public blog nobody can read. The policy is not false for anonymous&lt;br&gt;
visitors, it is never evaluated for them, and with no other permissive policy&lt;br&gt;
they get nothing. &lt;code&gt;to public&lt;/code&gt; means every role. &lt;code&gt;to authenticated&lt;/code&gt; means&lt;br&gt;
signed-in users only.&lt;/p&gt;
&lt;h2&gt;
  
  
  The key that ignores all of it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;service_role&lt;/code&gt; has the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute, so policies on a table are skipped&lt;br&gt;
entirely for it.&lt;/p&gt;

&lt;p&gt;That is correct for a trusted backend doing admin work and catastrophic anywhere&lt;br&gt;
near a browser. The service role key is a master key to your database, and no&lt;br&gt;
policy you write will contain it. Server side only, in an environment variable,&lt;br&gt;
never in client JavaScript.&lt;/p&gt;

&lt;p&gt;If you are debugging RLS and everything mysteriously works, check which key you&lt;br&gt;
are holding.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two more things that will save you
&lt;/h2&gt;

&lt;p&gt;A subquery inside a policy obeys RLS too. The standard team-membership pattern&lt;br&gt;
looks up a join table:&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;using&lt;/span&gt; &lt;span class="p"&gt;(&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="n"&gt;team_members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&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;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;team_members&lt;/code&gt; has RLS enabled and no policy letting the caller see their own&lt;br&gt;
membership row, that subquery finds nothing and every project disappears. The&lt;br&gt;
usual fix is a &lt;code&gt;security definer&lt;/code&gt; function that owns the lookup.&lt;/p&gt;

&lt;p&gt;Policies also run per row. A subquery in a policy is evaluated for each row&lt;br&gt;
scanned, not once per query. Wrapping a stable call as &lt;code&gt;(select auth.uid())&lt;/code&gt;&lt;br&gt;
lets the planner hoist it out, and the columns your policies filter on want&lt;br&gt;
indexes like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Before you debug a policy, check in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the role have a &lt;code&gt;GRANT&lt;/code&gt; on the table? If not, RLS never ran.&lt;/li&gt;
&lt;li&gt;Is RLS enabled with at least one policy that applies to this role?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;auth.uid()&lt;/code&gt; return anything, or is the request unauthenticated?&lt;/li&gt;
&lt;li&gt;Are you looking at &lt;code&gt;using&lt;/code&gt; when the problem is &lt;code&gt;with check&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Is a restrictive policy denying what a permissive one allowed?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most RLS problems are one of those five.&lt;/p&gt;




&lt;h2&gt;
  
  
  See it run
&lt;/h2&gt;

&lt;p&gt;The question you actually want answered is what a specific policy does to&lt;br&gt;
specific rows for a specific user, and that is easier to run than to argue&lt;br&gt;
about.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://rls-lab.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;RLS Lab&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It runs a real PostgreSQL database inside your browser: actual Postgres,&lt;br&gt;
compiled to WebAssembly, executing your policies rather than approximating them.&lt;br&gt;
You define a table, write some policies, and get a matrix of which rows each&lt;br&gt;
persona can see. Click any cell and it tells you which predicate returned false,&lt;br&gt;
and whether the grant layer or the RLS layer stopped you.&lt;/p&gt;

&lt;p&gt;Every failure mode in this post ships as a preset you can load and poke at,&lt;br&gt;
including the ones that are broken on purpose. Nothing you type leaves your&lt;br&gt;
machine, and any scenario you build becomes a URL you can paste into a thread.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rls-lab.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;Start with the guided tutorial →&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>Two errors, one error code, and an hour in the wrong editor : What i learnt building WP-Supabase Sync</title>
      <dc:creator>Claudio Ibe</dc:creator>
      <pubDate>Fri, 07 Aug 2026 18:38:46 +0000</pubDate>
      <link>https://dev.to/0xclaudi0/two-errors-one-error-code-and-an-hour-in-the-wrong-editor-what-i-learnt-building-wp-supabase-22mh</link>
      <guid>https://dev.to/0xclaudi0/two-errors-one-error-code-and-an-hour-in-the-wrong-editor-what-i-learnt-building-wp-supabase-22mh</guid>
      <description>&lt;p&gt;If you write to Supabase from anything, sooner or later Postgres hands you&lt;br&gt;
SQLSTATE &lt;code&gt;42501&lt;/code&gt;. It means one of two things:&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;permission&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;wp_content&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="nv"&gt;"wp_content"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first says your &lt;code&gt;GRANT&lt;/code&gt; is missing. Postgres refused before row level&lt;br&gt;
security was ever consulted, so your policies are irrelevant. You can rewrite&lt;br&gt;
them all afternoon and nothing will change. The second says the grant is fine and&lt;br&gt;
a policy's &lt;code&gt;WITH CHECK&lt;/code&gt; rejected the row.&lt;/p&gt;

&lt;p&gt;Same code, opposite fixes. And the advice you find first is almost always the&lt;br&gt;
policy one, because that's the interesting failure and it's what people blog&lt;br&gt;
about. So you go read your policies, and they look correct, because they are.&lt;/p&gt;

&lt;p&gt;I built a WordPress plugin recently that pushes content into Supabase, and this&lt;br&gt;
specific confusion is the thing it's organised around.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/0xclaudi0/wp-supabase-sync" rel="noopener noreferrer"&gt;WP Supabase Sync&lt;/a&gt; mirrors&lt;br&gt;
published WordPress posts into a Postgres table on your Supabase project.&lt;br&gt;
WordPress stays the editor and the source of truth. Supabase becomes the read&lt;br&gt;
layer your Next.js app or mobile client queries directly, with the anon key and&lt;br&gt;
RLS on top, instead of going through the WP REST API.&lt;/p&gt;

&lt;p&gt;That part is not hard. Post saves, hook fires, row gets upserted. Any competent&lt;br&gt;
afternoon produces a working version.&lt;/p&gt;

&lt;p&gt;What takes the time is everything after "working."&lt;/p&gt;
&lt;h2&gt;
  
  
  The diagnostics are the product
&lt;/h2&gt;

&lt;p&gt;There's a &lt;code&gt;wp supabase doctor&lt;/code&gt; command, and a matching admin screen, that runs&lt;br&gt;
twelve checks in dependency order. When something fails it names the layer that&lt;br&gt;
refused you, quotes what Postgres actually returned, and prints the SQL that&lt;br&gt;
fixes it.&lt;/p&gt;

&lt;p&gt;For the two &lt;code&gt;42501&lt;/code&gt; cases above, that's the difference between "check your RLS&lt;br&gt;
policies" and:&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;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="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&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;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;wp_content&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction is pinned by a test that drops the grant and asserts the wording.&lt;br&gt;
Which sounds like overkill for an error message, except a wrong one sends you&lt;br&gt;
somewhere else for an hour.&lt;/p&gt;

&lt;p&gt;Two smaller decisions in the same spirit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures don't cascade.&lt;/strong&gt; If the project is unreachable, the eleven downstream&lt;br&gt;
checks report as &lt;em&gt;skipped&lt;/em&gt;, not failed. A privilege failure upstream makes "can&lt;br&gt;
you write?" unanswerable, so the honest answer is to not answer it. One accurate&lt;br&gt;
red line beats eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every translated error is recorded from a real response.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;tests/fixtures/errors/&lt;/code&gt; holds actual PostgREST replies, captured by deliberately&lt;br&gt;
provoking each one against a live stack: &lt;code&gt;PGRST205&lt;/code&gt;, &lt;code&gt;PGRST204&lt;/code&gt;, &lt;code&gt;PGRST301&lt;/code&gt;,&lt;br&gt;
both &lt;code&gt;42501&lt;/code&gt; variants, &lt;code&gt;23505&lt;/code&gt;, &lt;code&gt;23502&lt;/code&gt;, &lt;code&gt;22P02&lt;/code&gt;, &lt;code&gt;42703&lt;/code&gt;. None of the strings&lt;br&gt;
the translator matches on is one I imagined. That's the only reason I'm willing&lt;br&gt;
to claim the &lt;code&gt;42501&lt;/code&gt; disambiguation works.&lt;/p&gt;

&lt;p&gt;I couldn't provoke &lt;code&gt;42P01 relation does not exist&lt;/code&gt; at all. PostgREST checks&lt;br&gt;
its own schema cache first and returns &lt;code&gt;PGRST205&lt;/code&gt; before Postgres sees the query,&lt;br&gt;
so it never surfaces over the Data API. The translator handles it anyway, since&lt;br&gt;
it can arrive via an RPC call into a function referencing a dropped table, but&lt;br&gt;
there's no fixture and the docs say so rather than quietly implying coverage.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things testing taught me that I'd have got wrong
&lt;/h2&gt;

&lt;p&gt;I wrote a spec first, then built against a real local stack, and kept a file of&lt;br&gt;
every place reality contradicted the plan. A few worth passing on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;identity&lt;/code&gt; and &lt;code&gt;serial&lt;/code&gt; are not interchangeable, and nothing tells you so until&lt;br&gt;
a locked-down role can't insert.&lt;/strong&gt; I wanted to confirm that &lt;code&gt;service_role&lt;/code&gt; could&lt;br&gt;
insert without an explicit sequence grant. First measurement said yes. First&lt;br&gt;
measurement was garbage: Postgres's default privileges had already granted &lt;code&gt;service_role&lt;/code&gt;&lt;br&gt;
UPDATE on the sequence, and &lt;code&gt;nextval()&lt;/code&gt; accepts USAGE &lt;em&gt;or&lt;/em&gt; UPDATE, so the test&lt;br&gt;
proved nothing.&lt;/p&gt;

&lt;p&gt;Revoking everything and comparing both column styles directly:&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;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SUCCEEDED&lt;/span&gt;
&lt;span class="nb"&gt;SERIAL&lt;/span&gt;   &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FAILED&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="n"&gt;wpsb_serial_probe_id_seq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An identity column's sequence is owned by the table and its privileges aren't&lt;br&gt;
checked separately. A &lt;code&gt;serial&lt;/code&gt; column's sequence is its own object and needs its&lt;br&gt;
own grant. It is a one-word schema choice that silently decides whether your&lt;br&gt;
inserts work under a locked-down role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No API key doesn't mean no access, locally.&lt;/strong&gt; My spec had "is the project&lt;br&gt;
reachable" and "is the key valid" as separate checks against &lt;code&gt;GET /rest/v1/&lt;/code&gt;.&lt;br&gt;
They aren't separable there: with no key at all, a local stack returns 200 and&lt;br&gt;
the full OpenAPI document, and &lt;code&gt;GET /rest/v1/posts?limit=1&lt;/code&gt; returns rows, because&lt;br&gt;
no key behaves as &lt;code&gt;anon&lt;/code&gt;. Hosted Supabase returns 401. So a check written against&lt;br&gt;
local behaviour would mean something different in production.&lt;/p&gt;

&lt;p&gt;The fix was better than the original plan anyway. The reachability check sends&lt;br&gt;
&lt;em&gt;no&lt;/em&gt; credentials and passes on any HTTP response including a 401, since all it&lt;br&gt;
asks is whether there's a Supabase there. The auth check sends the key and fails&lt;br&gt;
on 401/403. Now "wrong URL" and "wrong key" are cleanly separated and behave&lt;br&gt;
identically local and hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every key is a JWT any more.&lt;/strong&gt; The spec said decode the key and read the&lt;br&gt;
&lt;code&gt;role&lt;/code&gt; claim. That only works for legacy keys. A current &lt;code&gt;supabase start&lt;/code&gt; emits&lt;br&gt;
both generations, and &lt;code&gt;sb_secret_…&lt;/code&gt; and &lt;code&gt;sb_publishable_…&lt;/code&gt; are opaque. A JWT-only&lt;br&gt;
check would report "malformed key" for a perfectly good secret key on any modern&lt;br&gt;
project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;human_time_diff()&lt;/code&gt; is unsigned.&lt;/strong&gt; My cron check rendered an event that was due&lt;br&gt;
three hours ago as "next run due in 3 hours." A stopped scheduler, described as a&lt;br&gt;
healthy one, by the plugin whose entire pitch is not doing that. I found it by&lt;br&gt;
looking at a screenshot, not from a test. It now checks the direction and says&lt;br&gt;
the schedule was due N ago and hasn't run. Four regression assertions hold it&lt;br&gt;
there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous parts
&lt;/h2&gt;

&lt;p&gt;Writes go through a queue table rather than straight out over HTTP. A unique key&lt;br&gt;
on &lt;code&gt;(object_type, object_id)&lt;/code&gt; coalesces events, so editing 100 posts fires&lt;br&gt;
several hundred hooks and produces exactly 100 rows. Batches of 50, one request&lt;br&gt;
per action. Backoff at &lt;code&gt;2^attempts&lt;/code&gt; minutes capped at an hour, retrying only 5xx,&lt;br&gt;
429 and timeouts — a 401 or a missing table dead-letters immediately instead of&lt;br&gt;
burning eight attempts pretending it might resolve itself.&lt;/p&gt;

&lt;p&gt;Claiming a batch uses a token unique to the call, not a &lt;code&gt;claimed_at&lt;/code&gt; timestamp.&lt;br&gt;
Two overlapping cron runs can stamp the same second and each would then read the&lt;br&gt;
other's rows.&lt;/p&gt;

&lt;p&gt;Three other choices:&lt;/p&gt;

&lt;p&gt;The plugin talks to the Data API over HTTPS rather than opening a Postgres&lt;br&gt;
connection. PHP's request-per-process model exhausts a pool fast, &lt;code&gt;pdo_pgsql&lt;/code&gt; is&lt;br&gt;
often missing on managed WordPress hosts, and port 443 survives restrictive&lt;br&gt;
egress rules.&lt;/p&gt;

&lt;p&gt;The plugin never runs DDL. &lt;code&gt;wp supabase schema --print&lt;/code&gt; emits a migration for you&lt;br&gt;
to read and apply. Handing a WordPress plugin authority to alter your schema with&lt;br&gt;
a key that bypasses RLS is more power than it needs to do its job.&lt;/p&gt;

&lt;p&gt;Syncing is off by default and stays off until you've run the diagnostics.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;GPL-2.0, plain PHP, no build step, WordPress 6.4+ and PHP 8.1+.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/0xclaudi0/wp-supabase-sync" rel="noopener noreferrer"&gt;https://github.com/0xclaudi0/wp-supabase-sync&lt;/a&gt;&lt;br&gt;
Not affiliated with Supabase.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>php</category>
    </item>
    <item>
      <title>test!</title>
      <dc:creator>Claudio Ibe</dc:creator>
      <pubDate>Thu, 06 Aug 2026 23:59:50 +0000</pubDate>
      <link>https://dev.to/0xclaudi0/test-3354</link>
      <guid>https://dev.to/0xclaudi0/test-3354</guid>
      <description>&lt;p&gt;Test post for my blog&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
