<?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: Shipsealed</title>
    <description>The latest articles on DEV Community by Shipsealed (@mateuszingano).</description>
    <link>https://dev.to/mateuszingano</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%2F4029304%2F34c5fa6b-7d22-4eb5-9ede-d5632f09d09b.png</url>
      <title>DEV Community: Shipsealed</title>
      <link>https://dev.to/mateuszingano</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mateuszingano"/>
    <language>en</language>
    <item>
      <title>Two exploits, one public API key: the day I attacked my own Supabase app</title>
      <dc:creator>Shipsealed</dc:creator>
      <pubDate>Tue, 28 Jul 2026 22:42:37 +0000</pubDate>
      <link>https://dev.to/mateuszingano/two-exploits-one-public-api-key-the-day-i-attacked-my-own-supabase-app-2gno</link>
      <guid>https://dev.to/mateuszingano/two-exploits-one-public-api-key-the-day-i-attacked-my-own-supabase-app-2gno</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every app has its rockstar bug. Mine didn't crash, didn't throw, didn't page me at 3am. It just sat there in production — behind a paying customer's login — waiting for anyone with a browser to walk on stage and take a bow.&lt;/p&gt;

&lt;p&gt;Then I decided to attack my own app with nothing but the API key I ship to every visitor. Two exploits took a bow that day. Here's how I caught them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: the key you hand to strangers
&lt;/h2&gt;

&lt;p&gt;I run &lt;strong&gt;Zingui&lt;/strong&gt;, a small family-finance app, on &lt;strong&gt;Next.js + Supabase&lt;/strong&gt;. Real users, real paying subscriptions. Like every Supabase app, the browser ships with a &lt;strong&gt;public anon key&lt;/strong&gt; — that's by design. Row Level Security (RLS) and function grants are supposed to be the wall that makes a public key safe.&lt;/p&gt;

&lt;p&gt;The uncomfortable question I finally asked: &lt;em&gt;if I open the network tab, copy that anon key, and talk to the database directly — not through my UI — what can I do?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I opened a plain REST client, pointed it at my own PostgREST endpoint with the anon key, and started poking. No login. No session. Just the key everyone already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: the stranger who could cancel your paid plan
&lt;/h2&gt;

&lt;p&gt;My app has a "redeem promo code" action and a "switch to family plan" action. Both were Postgres functions. Both looked like this (simplified):&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;-- The footgun&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;aplicar_promo_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_familia&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_code&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;void&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="c1"&gt;-- runs as the function OWNER, bypassing RLS&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;update&lt;/span&gt; &lt;span class="n"&gt;assinaturas&lt;/span&gt;
     &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'trial'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;-- &amp;lt;-- always resets to trial&lt;/span&gt;
         &lt;span class="n"&gt;expira_em&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;
   &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;familia_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_familia&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;-- &amp;lt;-- no "is this MY family?" check&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;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;aplicar_promo_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;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="c1"&gt;-- &amp;lt;-- anon can call it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three words did the damage: &lt;strong&gt;&lt;code&gt;security definer&lt;/code&gt;&lt;/strong&gt;. That flag makes the function run as its owner and &lt;strong&gt;bypass RLS entirely&lt;/strong&gt;. Combined with &lt;code&gt;grant execute ... to anon&lt;/code&gt; and no ownership check, this meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anyone, with no account at all&lt;/strong&gt;, could call &lt;code&gt;aplicar_promo_code&lt;/code&gt; for &lt;em&gt;any&lt;/em&gt; &lt;code&gt;familia_id&lt;/code&gt; and change that family's subscription.&lt;/li&gt;
&lt;li&gt;Worse — the function &lt;em&gt;always&lt;/em&gt; set &lt;code&gt;status = 'trial'&lt;/code&gt;. So firing it at a &lt;strong&gt;paying&lt;/strong&gt; customer didn't upgrade them. It &lt;strong&gt;downgraded&lt;/strong&gt; them. A stranger could quietly knock a paying subscription back down to a trial clock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reproduced it live against my own production project with the anon key and zero authentication. It worked. That's a confirmed, exploitable-from-the-internet billing bug in an app with paying users. Cue the drums.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: the member who could crown themselves
&lt;/h2&gt;

&lt;p&gt;The second one lived in an RLS policy on the &lt;code&gt;membros&lt;/code&gt; (household members) 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="c1"&gt;-- The footgun&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;membros_all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;membros&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;                                     &lt;span class="c1"&gt;-- SELECT + INSERT + UPDATE + DELETE&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;familia_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;minha_familia&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;-- USING only, no WITH CHECK, no column limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;for all&lt;/code&gt; with only a &lt;code&gt;using&lt;/code&gt; clause and no &lt;code&gt;with check&lt;/code&gt; is a classic Supabase trap. &lt;code&gt;using&lt;/code&gt; decides which rows you can &lt;em&gt;see and touch&lt;/em&gt;; &lt;code&gt;with check&lt;/code&gt; decides what you're allowed to &lt;em&gt;write&lt;/em&gt;. With &lt;code&gt;with check&lt;/code&gt; missing, any logged-in member — talking straight to PostgREST, bypassing my UI — could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;flip their own &lt;code&gt;is_admin&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; and become a household manager,&lt;/li&gt;
&lt;li&gt;lock the actual owner out,&lt;/li&gt;
&lt;li&gt;edit columns the app never exposed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A regular member could crown themselves king of a household they were only invited to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: take the stage back
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For the billing functions:&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="c1"&gt;-- Only the server may call these now&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;aplicar_promo_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;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;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;aplicar_promo_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;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 endpoints now resolve the family &lt;strong&gt;from the authenticated session on the server&lt;/strong&gt; and call the function via the service role — the client can't name an arbitrary &lt;code&gt;familia_id&lt;/code&gt; anymore. I also fixed the function itself to &lt;strong&gt;never downgrade a valid paid subscription&lt;/strong&gt; (base the new expiry on &lt;code&gt;max(now(), expira_em)&lt;/code&gt; instead of blindly resetting to &lt;code&gt;trial&lt;/code&gt;), and pinned &lt;code&gt;search_path&lt;/code&gt; to close the SECURITY DEFINER search-path hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the member policy&lt;/strong&gt;, I split the one greedy &lt;code&gt;for all&lt;/code&gt; into intent-specific policies and added column-level grants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;membros_select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;membros&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;familia_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;minha_familia&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="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;membros_update_self&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;membros&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meu_membro_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;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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meu_membro_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;-- &amp;lt;-- the guard that was missing&lt;/span&gt;

&lt;span class="c1"&gt;-- authenticated can only write the harmless columns; is_admin is not one of them&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nome&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;forma_pagamento_preferida&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onboarding_visto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;membros&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;Promotions to manager now go through a server path that checks who's asking. &lt;code&gt;is_admin&lt;/code&gt; is simply &lt;strong&gt;not grantable&lt;/strong&gt; to &lt;code&gt;authenticated&lt;/code&gt; anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after, proven in prod
&lt;/h2&gt;

&lt;p&gt;I don't trust a fix I haven't tried to break again. So I verified on the real production database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-ran both exploits as &lt;code&gt;anon&lt;/code&gt;&lt;/strong&gt; after the patch → the promo function returns &lt;em&gt;permission denied&lt;/em&gt;; the member update can't touch &lt;code&gt;is_admin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrapped destructive checks in &lt;code&gt;begin … rollback&lt;/code&gt;&lt;/strong&gt; so I could prove behavior against live data without persisting anything — e.g. confirming a paid-and-valid subscription is left untouched when the promo path runs.&lt;/li&gt;
&lt;li&gt;Added a &lt;strong&gt;regression test&lt;/strong&gt; for the intra-family privilege escalation so a future migration can't quietly reopen it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before: a public key was a loaded gun. After: the anon key can read what it should and nothing it shouldn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;The bugs were loud in impact but silent in the codebase — no error, no log, no stack trace. Four things I now treat as non-negotiable on any Supabase project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security definer&lt;/code&gt; + &lt;code&gt;grant execute to anon&lt;/code&gt; is a combo, not two settings.&lt;/strong&gt; A definer function bypasses RLS, so its grant list &lt;em&gt;is&lt;/em&gt; your security boundary. Audit them together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;for all&lt;/code&gt; policies are a smell.&lt;/strong&gt; Split by intent (&lt;code&gt;select&lt;/code&gt; / &lt;code&gt;insert&lt;/code&gt; / &lt;code&gt;update&lt;/code&gt; / &lt;code&gt;delete&lt;/code&gt;) so each gets the right clause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;using&lt;/code&gt; is not &lt;code&gt;with check&lt;/code&gt;.&lt;/strong&gt; If a policy can write and it has no &lt;code&gt;with check&lt;/code&gt;, it can write things you didn't mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant columns, not tables.&lt;/strong&gt; &lt;code&gt;grant update (col, col)&lt;/code&gt; is the difference between "edit your avatar" and "make yourself admin."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Catching these two live was the thing that made me go RLS-first on every project since. If you want to &lt;em&gt;see&lt;/em&gt; a cross-tenant leak instead of just reading about one, I put together a &lt;a href="https://shipsealed.com/#demo" rel="noopener noreferrer"&gt;live demo against real Postgres&lt;/a&gt; — same table, one policy leaks, one blocks, and you can re-run the request as the other tenant to prove the row was there all along. The free MIT tools next to it (&lt;code&gt;airlock-rls&lt;/code&gt;, &lt;code&gt;airlock-migrate&lt;/code&gt;) fail your build when a table ships with RLS off — not a silver bullet for every footgun above, but the cheapest way to stop the most common one. Turns out the best way to stop giving your rockstar bugs a stage is to stop building the stage in the first place.&lt;/p&gt;

&lt;p&gt;Smash responsibly. 🔨&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>supabase</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Supabase RLS gotcha nobody warns you about: infinite recursion in multi-tenant policies</title>
      <dc:creator>Shipsealed</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:42:17 +0000</pubDate>
      <link>https://dev.to/mateuszingano/the-supabase-rls-gotcha-nobody-warns-you-about-infinite-recursion-in-multi-tenant-policies-42jm</link>
      <guid>https://dev.to/mateuszingano/the-supabase-rls-gotcha-nobody-warns-you-about-infinite-recursion-in-multi-tenant-policies-42jm</guid>
      <description>&lt;p&gt;You're building teams/workspaces on Supabase. A row belongs to a workspace, and a user can touch it if they're a member. Simple enough — until your policies start throwing &lt;strong&gt;infinite recursion&lt;/strong&gt; and you have no idea why.&lt;/p&gt;

&lt;p&gt;Here's the trap and the pattern that avoids it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The setup
&lt;/h3&gt;

&lt;p&gt;A membership table, with RLS on it too (of course):&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;workspace_members&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;workspaces&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;references&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;users&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;workspace_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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;workspace_members&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own memberships"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;workspace_members&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;user_id&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The trap
&lt;/h3&gt;

&lt;p&gt;Now you scope a tenant table by asking "which workspaces is this user in?" — by querying &lt;code&gt;workspace_members&lt;/code&gt; &lt;strong&gt;inside the policy&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="c1"&gt;-- ⚠️ this can recurse&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;"members read projects"&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;workspace_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;workspace_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;workspace_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="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="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 on &lt;code&gt;projects&lt;/code&gt; queries &lt;code&gt;workspace_members&lt;/code&gt;, which has its &lt;strong&gt;own&lt;/strong&gt; RLS policy, which re-evaluates… you've built a loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: a security-definer function
&lt;/h3&gt;

&lt;p&gt;Resolve membership with a function that runs with the &lt;strong&gt;definer's&lt;/strong&gt; rights, so reading &lt;code&gt;workspace_members&lt;/code&gt; doesn't re-trigger RLS. The policy becomes a plain &lt;code&gt;IN&lt;/code&gt; check — no recursion:&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_workspace_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;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="c1"&gt;-- pin it: this is the safety bit&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;workspace_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;workspace_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;"members read projects"&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;workspace_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_workspace_ids&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;strong&gt;Why it's safe:&lt;/strong&gt; the function is read-only, returns only the caller's &lt;em&gt;own&lt;/em&gt; workspace ids, and &lt;code&gt;search_path = ''&lt;/code&gt; prevents search-path hijacking (the classic &lt;code&gt;SECURITY DEFINER&lt;/code&gt; footgun). It never exposes another user's memberships.&lt;/p&gt;

&lt;p&gt;Do this for every tenant table (reads &lt;em&gt;and&lt;/em&gt; writes — remember &lt;code&gt;WITH CHECK&lt;/code&gt; on inserts), and every row is scoped to the caller's workspaces by construction.&lt;/p&gt;

&lt;p&gt;Full pattern — including the write policies and a tenant-isolation test — here: &lt;strong&gt;&lt;a href="https://shipsealed.com/rls/multi-tenant-rls-workspaces-supabase/" rel="noopener noreferrer"&gt;Multi-tenant RLS with workspaces in Supabase →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;🦭&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>rls</category>
      <category>multitenancy</category>
    </item>
    <item>
      <title>Fix: "new row violates row-level security policy" in Supabase (it's not what you think)</title>
      <dc:creator>Shipsealed</dc:creator>
      <pubDate>Tue, 14 Jul 2026 22:24:23 +0000</pubDate>
      <link>https://dev.to/mateuszingano/fix-new-row-violates-row-level-security-policy-in-supabase-its-not-what-you-think-2g08</link>
      <guid>https://dev.to/mateuszingano/fix-new-row-violates-row-level-security-policy-in-supabase-its-not-what-you-think-2g08</guid>
      <description>&lt;p&gt;If you've shipped anything on Supabase, you've hit this:&lt;/p&gt;

&lt;p&gt;​&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new row violates row-level security policy for table "notes"
​```



Your first instinct is panic — *is my data exposed?* Here's the twist:

**This error is good news.** RLS is **on** and doing its job: deny by default. The dangerous state is the *opposite* — RLS **off**, no error, table public to anyone with your anon key. That's the #1 Supabase leak. So don't reach for the "disable RLS" button. You just need a policy that permits the legitimate write.

There are exactly three reasons the write gets denied:

**Cause 1 — no INSERT policy at all (most common).**
You enabled RLS, added a `SELECT` policy, and moved on. Reads work, writes are denied. RLS is default-deny **per operation** — a read policy does nothing for inserts.

**Cause 2 — the row's `user_id` doesn't equal `auth.uid()`.**
You have `with check (user_id = auth.uid())`, but the client inserted the row without setting `user_id` (so it's `null`), or set a different one. The check evaluates false → violation.

**Cause 3 — the request isn't authenticated.**
Running with the anon key and no session? `auth.uid()` is `null`, so `user_id = auth.uid()` can never be true.

### The fix

Give the table an INSERT policy that lets a signed-in user write **their own** rows, and make the row carry their id automatically:

​

```sql
-- a signed-in user may insert rows that belong to them
create policy "owner inserts own notes" on notes
  for insert
  with check (user_id = (select auth.uid()));

-- stop trusting the client to send user_id — default it server-side
alter table notes
  alter column user_id set default (select auth.uid());
​```



Two gotchas worth internalizing:

- **`USING` vs `WITH CHECK`:** `USING` filters which existing rows you can see/touch; `WITH CHECK` validates the *new* row on INSERT/UPDATE. For inserts, `WITH CHECK` is the one that matters.
- **"It works in the SQL editor but fails from my app."** The SQL editor runs as a privileged role that **bypasses RLS**. Your app runs as `authenticated` / `anon`, which RLS applies to. Always test writes the way your app makes them — signed in, through the client.

I wrote the **full version with a copy-paste test that proves the policy** (and the `UPDATE` case) here: **[Fix: new row violates row-level security policy →](https://shipsealed.com/rls/fix-new-row-violates-row-level-security-policy/)**

Keep RLS on. Ship it sealed. 🦭
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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