<?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: Ashton Latham</title>
    <description>The latest articles on DEV Community by Ashton Latham (@ashtondev1164).</description>
    <link>https://dev.to/ashtondev1164</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%2F4101511%2F28a75887-6645-4daa-b1f4-30b41f576f3f.jpg</url>
      <title>DEV Community: Ashton Latham</title>
      <link>https://dev.to/ashtondev1164</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashtondev1164"/>
    <language>en</language>
    <item>
      <title>The Supabase RLS mistake that lets any user join any organization</title>
      <dc:creator>Ashton Latham</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:24:38 +0000</pubDate>
      <link>https://dev.to/ashtondev1164/the-supabase-rls-mistake-that-lets-any-user-join-any-organization-1anc</link>
      <guid>https://dev.to/ashtondev1164/the-supabase-rls-mistake-that-lets-any-user-join-any-organization-1anc</guid>
      <description>&lt;p&gt;It is four lines long, it reads correctly out loud, and it appears in a lot of multi-tenant Supabase schemas. It checks &lt;em&gt;who&lt;/em&gt; is writing the row and never checks &lt;em&gt;which tenant&lt;/em&gt; the row belongs to.&lt;/p&gt;

&lt;p&gt;Nearly every B2B app converges on the same three tables: organizations, a membership join table, and whatever the customer actually came for. Access is decided by membership, row-level security enforces it in Postgres, and the whole thing is genuinely more robust than checking permissions in application code. A missed &lt;code&gt;where&lt;/code&gt; clause in a route handler can't leak data past a policy.&lt;/p&gt;

&lt;p&gt;That is exactly why the bug below is worth knowing about. It doesn't live in the policies people scrutinise. It lives in the one nobody reads twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema
&lt;/h2&gt;

&lt;p&gt;Standard shape. Organizations are the tenant boundary, membership decides who sees what, and &lt;code&gt;projects&lt;/code&gt; stands in for whatever your product's real resource is.&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;table&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;        &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;owner_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;organization_members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;role&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'member'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&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;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads are scoped through a helper. It needs &lt;code&gt;security definer&lt;/code&gt; because a policy on &lt;code&gt;organization_members&lt;/code&gt; that queries &lt;code&gt;organization_members&lt;/code&gt; re-enters itself, and Postgres raises &lt;em&gt;"infinite recursion detected in policy for relation"&lt;/em&gt;. Running the lookup as the function's owner takes it out of the policy's reach and breaks the loop.&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;user_org_ids&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;setof&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;stable&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="k"&gt;public&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;organization_members&lt;/span&gt; &lt;span class="k"&gt;where&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="err"&gt;$$&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;"read projects in your orgs"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;projects&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;organization_id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;user_org_ids&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So far this is fine. Every read of &lt;code&gt;projects&lt;/code&gt; is filtered to organizations the caller belongs to. Test it with two accounts and it behaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy that looks right
&lt;/h2&gt;

&lt;p&gt;Somewhere you have to let a user become a member. At minimum, the person who just created an organization needs a membership row in it. So you write the obvious policy:&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="c1"&gt;-- checks who, never which org&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;"users can insert their own membership"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;organization_members&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;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;Read it as a sentence: &lt;em&gt;"you may add a membership row as long as the row is about you."&lt;/em&gt; That sounds like a security check. It constrains &lt;strong&gt;who&lt;/strong&gt; is being added, and says nothing at all about &lt;strong&gt;which organization&lt;/strong&gt; they are being added to.&lt;/p&gt;

&lt;p&gt;Organization ids are not secret. They travel in URLs, in API responses, in invite links, in error messages, in support tickets. Assume any user has seen at least one that isn't theirs.&lt;/p&gt;

&lt;p&gt;Any authenticated user, from the browser, against any organization id they have ever seen:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;organization_members&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;someOtherOrgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;myUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner&lt;/span&gt;&lt;span class="dl"&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;The policy passes: &lt;code&gt;user_id&lt;/code&gt; really is &lt;code&gt;auth.uid()&lt;/code&gt;. They are now an owner of a tenant they have no relationship to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the read policies don't save you
&lt;/h2&gt;

&lt;p&gt;This is the part that makes it dangerous rather than merely wrong. Every &lt;code&gt;SELECT&lt;/code&gt; policy in the schema keys off membership. That was the whole design. So the moment the attacker owns a membership row, those policies start working &lt;em&gt;for&lt;/em&gt; them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The read policies were never broken. They were asked the wrong question, and they answered it correctly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Projects, documents, members, billing records: anything scoped by &lt;code&gt;user_org_ids()&lt;/code&gt; is now in scope, because the attacker genuinely is a member as far as Postgres is concerned. One &lt;code&gt;INSERT&lt;/code&gt; converts into read access across the entire tenant, and often write access too. Because &lt;code&gt;role&lt;/code&gt; was unconstrained, they took &lt;code&gt;'owner'&lt;/code&gt; on the way in.&lt;/p&gt;

&lt;p&gt;Nothing in your logs looks unusual. There is no failed request, no permission error, no anomalous query. Just a membership row that shouldn't exist, and a user quietly reading someone else's data through policies working exactly as written.&lt;/p&gt;

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

&lt;p&gt;Constrain the tenant, not just the actor. The only legitimate reason a client inserts a membership row is an owner bootstrapping the organization they just created. Everything else, invite acceptance or an admin adding a colleague, belongs server-side in a &lt;code&gt;security definer&lt;/code&gt; function or behind the service role, where RLS is not the thing doing the checking.&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;"owner bootstraps their own membership"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;organization_members&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;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;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'owner'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="k"&gt;in&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;id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;owner_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;Three clauses, three distinct jobs, and dropping any one reopens a hole:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;user_id = auth.uid()&lt;/code&gt; - the row is about you, so you can't add anyone else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;role = 'owner'&lt;/code&gt; - you aren't inventing a privilege level for yourself on the way in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;organization_id in (...)&lt;/code&gt; - the organization is one you actually own. This is the clause that was missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you know it's fixed?
&lt;/h2&gt;

&lt;p&gt;This is the part that usually gets skipped, and it matters more than the policy, because a policy you haven't tested is a guess. There are two traps that make RLS tests pass while the policy leaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Superusers bypass RLS unconditionally
&lt;/h3&gt;

&lt;p&gt;If you connect with a &lt;code&gt;psql&lt;/code&gt; connection string, or as the table owner, or as &lt;code&gt;postgres&lt;/code&gt;, &lt;strong&gt;every policy passes no matter how broken it is&lt;/strong&gt;. Row-level security simply does not apply to superusers, and &lt;code&gt;alter table ... enable row level security&lt;/code&gt; doesn't change that.&lt;/p&gt;

&lt;p&gt;Tests have to run as a non-superuser, through the same &lt;code&gt;auth.uid()&lt;/code&gt; path your application uses. If your RLS test suite has never been run as &lt;code&gt;authenticated&lt;/code&gt;, it is not testing anything at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Denials are usually silent
&lt;/h3&gt;

&lt;p&gt;Which clause rejects you determines what you observe:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rejected by&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silence. The row was never visible, so the statement affects zero rows and reports success.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An error: &lt;em&gt;"new row violates row-level security policy"&lt;/em&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A blocked &lt;code&gt;SELECT&lt;/code&gt; returns an empty result, not an exception. A blocked &lt;code&gt;DELETE&lt;/code&gt; removes nothing and tells you it succeeded. So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never assert that something was blocked by checking a call didn't throw. Read the data back and assert on what is actually there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Then break it on purpose
&lt;/h3&gt;

&lt;p&gt;Put the vulnerable policy back and run your tests again. If nothing goes red, your suite would not have caught the real thing either. On the schema above, reintroducing the broken insert policy should fail the "cannot join an org you don't own" assertion &lt;em&gt;and&lt;/em&gt; a second assertion, because the attacker can now see a third tenant's projects. That cascade is the blast radius, reproduced in a few seconds.&lt;/p&gt;

&lt;p&gt;A test suite you have never watched fail is a decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps in the same neighbourhood
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; is also a read
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;supabase-js&lt;/code&gt; compiles &lt;code&gt;.insert().select()&lt;/code&gt; into &lt;code&gt;INSERT ... RETURNING&lt;/code&gt;, and Postgres applies the &lt;code&gt;SELECT&lt;/code&gt; policy to the returned row. A user creating an organization is not yet a member of it, so a membership-only read policy rejects the read-back, and the error says &lt;em&gt;"new row violates row-level security policy"&lt;/em&gt;, which reads like the insert was refused rather than the row being returned. Give the organizations read policy an &lt;code&gt;owner_id = auth.uid()&lt;/code&gt; branch and it resolves.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;UPDATE&lt;/code&gt; needs both clauses spelled out
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;USING&lt;/code&gt; chooses which rows you may touch; &lt;code&gt;WITH CHECK&lt;/code&gt; validates the row you leave behind. Postgres reuses &lt;code&gt;USING&lt;/code&gt; as the check when you omit it, so the default is safe. Write both anyway. The day someone widens &lt;code&gt;USING&lt;/code&gt;, having the second clause in front of them is what prompts the question of what a row is now allowed to become.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern behind the bug
&lt;/h2&gt;

&lt;p&gt;It is worth naming, because once you can hear it you will catch it in policies nobody has written yet:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A check that names the actor but not the boundary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The insert policy above checks &lt;em&gt;who&lt;/em&gt; but not &lt;em&gt;which tenant&lt;/em&gt;. A role check written as &lt;code&gt;exists (select 1 from organization_members where user_id = auth.uid() and role = 'admin')&lt;/code&gt; checks &lt;em&gt;what rank&lt;/em&gt; but not &lt;em&gt;where it is held&lt;/em&gt;, making anyone who is an admin somewhere an admin everywhere. A sharing policy phrased as "you may share what you can see" checks &lt;em&gt;visibility&lt;/em&gt; but not &lt;em&gt;authority&lt;/em&gt;, so every recipient becomes a distributor.&lt;/p&gt;

&lt;p&gt;Same shape every time. Read each policy aloud as a sentence and listen for the missing half.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write more of these at &lt;a href="https://keystone-landing-cyy.pages.dev/guides/" rel="noopener noreferrer"&gt;Scaffold&lt;/a&gt;, including guides on role hierarchies, invite acceptance, and security definer boundaries. Disclosure: I also sell a multi-tenant starter kit built on these patterns, but the guides stand alone and there's no signup wall.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I broke my own database with an infinite loop, and here's what I learned about Row Level Security</title>
      <dc:creator>Ashton Latham</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:57:04 +0000</pubDate>
      <link>https://dev.to/ashtondev1164/i-broke-my-own-database-with-an-infinite-loop-and-heres-what-i-learned-about-row-level-security-2i4</link>
      <guid>https://dev.to/ashtondev1164/i-broke-my-own-database-with-an-infinite-loop-and-heres-what-i-learned-about-row-level-security-2i4</guid>
      <description>&lt;p&gt;A couple of weeks ago I set out to build something I actually wanted to exist: a starter kit for B2B SaaS apps that handles the boring-but-critical stuff: auth, multi-tenancy, and team invites so I could stop rebuilding the same plumbing every time I had a new idea.&lt;/p&gt;

&lt;p&gt;It went fine, right up until Postgres told me I'd created an infinite loop in my own security policy.&lt;/p&gt;

&lt;p&gt;The setup&lt;/p&gt;

&lt;p&gt;Nothing fancy — Next.js, Supabase, Vercel. The whole point of the kit is that users belong to organisations, not just individual accounts, because that's how basically every real B2B product works (think Slack, Notion, Linear). Multiple people share access to the same workspace, and one org's data should never, ever leak into another org's view.&lt;/p&gt;

&lt;p&gt;That last part is where Row Level Security (RLS) comes in. RLS is Postgres's way of enforcing access rules at the database level instead of hoping your application code remembers to filter correctly every single time. You write a policy once, and Postgres enforces it on every query, no exceptions, no "oops I forgot the WHERE clause."&lt;/p&gt;

&lt;p&gt;I set up two tables: organisations and organization_members (a join table linking users to orgs). Then I wrote what seemed like a completely reasonable policy — members should only be able to see membership rows for organisations they're already part of:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
create policy "Users can view memberships in their orgs"&lt;br&gt;
on organization_members for select&lt;br&gt;
using (&lt;br&gt;
  organization_id in (&lt;br&gt;
    select organization_id from organization_members&lt;br&gt;
    where user_id = auth.uid()&lt;br&gt;
  )&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;Read that again slowly. I didn't, and Postgres made me regret it.&lt;/p&gt;

&lt;p&gt;infinite recursion detected in policy for relation "organization_members"&lt;/p&gt;

&lt;p&gt;The policy for organization_members checks whether a row is visible by... querying organization_members. Which triggers the policy again. Which queries the table again. Forever.&lt;/p&gt;

&lt;p&gt;It's such an obvious mistake in hindsight, but it's also an incredibly easy one to make, because the logic feels correct when you're writing it. "Only show rows for orgs the user belongs to" is a completely reasonable rule — I just implemented the check by querying the very table the policy was protecting, which meant every read of the table triggered another read of the table to authorise the first read.&lt;/p&gt;

&lt;p&gt;The fix: let a function do the dirty work&lt;/p&gt;

&lt;p&gt;The way out is a security definer function — a function that runs with elevated privileges, bypassing RLS internally so it can safely check membership without re-triggering the policy that's asking the question:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
create or replace function get_user_org_ids()&lt;br&gt;
returns setof uuid&lt;br&gt;
language sql&lt;br&gt;
security definer&lt;br&gt;
set search_path = public&lt;br&gt;
as $$&lt;br&gt;
  select organization_id from organization_members where user_id = auth.uid()&lt;br&gt;
$$;&lt;/p&gt;

&lt;p&gt;Then the policy calls the function instead of querying the table directly:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
create policy "Users can view memberships in their orgs"&lt;br&gt;
on organization_members for select&lt;br&gt;
using (&lt;br&gt;
  organization_id in (select * from get_user_org_ids())&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;The function still ultimately reads from organization_members, but because it runs as security definer, it steps outside the RLS check instead of triggering it again. The recursion stops, and the actual security logic is unchanged — users still only see orgs they belong to.&lt;/p&gt;

&lt;p&gt;A second, sneakier version of the same problem&lt;/p&gt;

&lt;p&gt;I thought I was done, but a few steps later I hit new row violates row-level security policy for table "organizations" — a different table, and at first this looked like a totally separate bug.&lt;/p&gt;

&lt;p&gt;It wasn't. When you do an insert-then-select in one call (which Supabase's client does automatically when you chain .insert().select()), Postgres inserts the row and then tries to hand it back to you by selecting it. My organisation's SELECT policy only allowed a user to see orgs they were already a member of — but at the exact moment of creation, the membership row didn't exist yet. So the insert succeeded, the select-back failed, and Postgres reported the whole thing as an RLS violation on the insert, which sent me looking in completely the wrong place for a while.&lt;/p&gt;

&lt;p&gt;The fix was just widening the SELECT policy slightly, so an org's owner can see it immediately, even before their membership row exists:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
create policy "Users can view their organisations"&lt;br&gt;
on organisations for select&lt;br&gt;
using (&lt;br&gt;
  owner_id = auth.uid()&lt;br&gt;
  or id in (select * from get_user_org_ids())&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;What I'd tell past-me&lt;/p&gt;

&lt;p&gt;RLS is genuinely one of the best tools for making sure you don't accidentally leak one customer's data to another — enforcing it at the database layer instead of trusting every code path to remember to filter correctly is worth the setup pain. But two things are easy to get wrong, and both bit me on the same afternoon:&lt;/p&gt;

&lt;p&gt;Don't write a policy on table X that queries table X to authorise itself. If your policy needs to check the same table it's protecting, wrap that check in a security definer function instead of querying it directly inside the policy.&lt;br&gt;
Remember that insert-then-select is two operations, not one, and your SELECT policy needs to actually allow the person who just inserted a row to read it back — which isn't automatic just because they were the one who created it.&lt;/p&gt;

&lt;p&gt;Neither of these is documented anywhere obvious until you hit them, which is exactly why I'm writing this down now, mostly for the next person (possibly future me) who gets the same cryptic recursion error and has no idea why a policy that reads perfectly correct in English is actually an infinite loop in practice.&lt;/p&gt;

&lt;p&gt;If you're setting up multi-tenant RLS for the first time, save yourself the afternoon I lost — write your membership-check policies through a security definer helper function from the start, not as an afterthought once Postgres yells at you.&lt;/p&gt;

&lt;p&gt;I packaged this whole setup—auth, orgs, RLS, invites—into a starter kit so I (and hopefully others) don't have to solve it from scratch again. Happy to answer questions if you're working through something similar.&lt;/p&gt;

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