<?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: Cenk KURTOĞLU</title>
    <description>The latest articles on DEV Community by Cenk KURTOĞLU (@cekuu35).</description>
    <link>https://dev.to/cekuu35</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%2F3995356%2Ff0bdbda0-d9ea-46f9-aa42-626005189d37.png</url>
      <title>DEV Community: Cenk KURTOĞLU</title>
      <link>https://dev.to/cekuu35</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cekuu35"/>
    <language>en</language>
    <item>
      <title>Multi-tenant RLS in Supabase: the 7 isolation bugs I find in every AI-built SaaS</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:35:20 +0000</pubDate>
      <link>https://dev.to/cekuu35/multi-tenant-rls-in-supabase-the-7-isolation-bugs-i-find-in-every-ai-built-saas-3a8a</link>
      <guid>https://dev.to/cekuu35/multi-tenant-rls-in-supabase-the-7-isolation-bugs-i-find-in-every-ai-built-saas-3a8a</guid>
      <description>&lt;h1&gt;
  
  
  Multi-tenant RLS in Supabase: the 7 isolation bugs I find in every AI-built SaaS
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;I review multi-tenant Supabase security for a living. Last month I audited an AI-built admission-gate system — here are the seven isolation bugs that keep showing up, in the exact order you should check for them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;by Cenk Kurtoglu — github.com/cekuu35&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. RLS enabled, zero policies = silent deny-all (or silent chaos)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; with no policies means every query returns empty — which developers "fix" by adding a policy that allows everything. Check: does every tenant-visible table have a policy set, and does every policy deny by default?&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;USING (true)&lt;/code&gt; policies that eat your restrictive ones
&lt;/h2&gt;

&lt;p&gt;Postgres combines permissive policies with OR. One sloppy &lt;code&gt;USING (true)&lt;/code&gt; policy on a table makes every other restrictive policy irrelevant — the OR chain lets anyone through. Two restrictive policies plus one permissive one = zero protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; &lt;code&gt;SELECT * FROM pg_policies WHERE tablename = 'your_table';&lt;/code&gt; — count permissive policies. Any &lt;code&gt;USING (true)&lt;/code&gt; that isn't deliberate is a tenant leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Missing WITH CHECK on UPDATE
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; policies have two halves: &lt;code&gt;USING&lt;/code&gt; (can I see the row to update?) and &lt;code&gt;WITH CHECK&lt;/code&gt; (can the row look like this AFTER my update?). Missing &lt;code&gt;WITH CHECK&lt;/code&gt; means a tenant can update someone else's row &lt;em&gt;if they can see it&lt;/em&gt; — or worse, mutate &lt;code&gt;tenant_id&lt;/code&gt; itself and migrate the row into their tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SECURITY DEFINER functions reachable by anonymous callers
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;anon&lt;/code&gt;-reachable SECURITY DEFINER function runs with owner privileges — bypassing every RLS policy on every table it touches. Grep your migrations: every &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function needs a &lt;code&gt;revoke all on function ... from public, anon&lt;/code&gt; and an explicit &lt;code&gt;grant&lt;/code&gt; to the roles that need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The INVOKER/privilege contradiction that breaks your claim RPC
&lt;/h2&gt;

&lt;p&gt;This one is subtle and I missed it on first pass in a recent audit (the client's own team caught it — credit where due). A claim function declared &lt;code&gt;SECURITY INVOKER&lt;/code&gt; while the target table's INSERT privilege is revoked from &lt;code&gt;authenticated&lt;/code&gt; (and no INSERT policy exists) means: the function executes with the &lt;em&gt;caller's&lt;/em&gt; rights, and the inner INSERT fails with permission-denied. The function "exists and is callable" but never succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; either SECURITY DEFINER with an internal &lt;code&gt;auth.uid()&lt;/code&gt; gate and &lt;code&gt;set search_path = ''&lt;/code&gt;, or INVOKER plus a scoped INSERT policy (&lt;code&gt;actor_user_id = auth.uid()&lt;/code&gt;). Then prove it with a three-session test: anon rejected, authenticated claims once, concurrent claim raises a serialization error.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Unique constraint + ON CONFLICT DO NOTHING = permanent lockout
&lt;/h2&gt;

&lt;p&gt;A lease table with &lt;code&gt;unique(org, tenant, run_key)&lt;/code&gt; and &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; prevents double-claims — but it ALSO permanently blocks legitimate re-claims after the lease expires or is revoked. The surviving row wins forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;code&gt;ON CONFLICT ... DO UPDATE SET ... WHERE your_table.lease_expires_at &amp;lt;= now() OR your_table.revoked_at IS NOT NULL&lt;/code&gt;. Live rows still conflict; dead rows yield. Add the three-session test: expired → re-claim succeeds; revoked → re-claim succeeds; live → conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. App-layer-only enforcement (the invisible single point of failure)
&lt;/h2&gt;

&lt;p&gt;If your scheduler returns "reservation commands" and some unreviewed executor applies them, your atomicity lives in code nobody audited. Enforcement that isn't in the database can be bypassed by any code path that skips it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the DB must own the invariant — constraints, exclusion rules, atomic claims. App code coordinates; the database decides.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 20-minute self-audit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List every table with &lt;code&gt;SELECT * FROM pg_tables WHERE schemaname = 'public';&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;For each: &lt;code&gt;SELECT * FROM pg_policies WHERE tablename = ...&lt;/code&gt; — deny by default? WITH CHECK everywhere?&lt;/li&gt;
&lt;li&gt;Grep migrations for &lt;code&gt;SECURITY DEFINER&lt;/code&gt; — revoke anon on every one&lt;/li&gt;
&lt;li&gt;Find every RPC your app calls — verify each definition's privileges and org binding&lt;/li&gt;
&lt;li&gt;Write the three-session concurrency test for any claim/lease table&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 or 4 scares you, that's the audit talking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I do fixed-scope multi-tenant Supabase security reviews (policy matrix, negative tests, ready-to-run fix SQL) — github.com/cekuu35, cenkkurtoglu.com. I also disclose leaked keys for free, permanently.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want the exact SQL pack?&lt;/strong&gt; All 7 checks + fix SQL + the 3-session test, packaged for a 20-minute self-audit: &lt;a href="https://cengokurtoglu.gumroad.com/l/mlzoou" rel="noopener noreferrer"&gt;Multi-Tenant RLS Self-Audit Pack&lt;/a&gt; - $39.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I re-verified 30 disclosed Supabase keys. 20% were still live.</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 22:00:50 +0000</pubDate>
      <link>https://dev.to/cekuu35/i-re-verified-30-disclosed-supabase-keys-20-were-still-live-5534</link>
      <guid>https://dev.to/cekuu35/i-re-verified-30-disclosed-supabase-keys-20-were-still-live-5534</guid>
      <description>&lt;h1&gt;
  
  
  I re-verified 30 disclosed Supabase keys. 20% were still live.
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;The uncomfortable finding: developers who thank you for reporting a leaked key — and swear they rotated it — often don't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;by Cenk Kurtoglu — I audit Supabase/Postgres access control for a living (github.com/cekuu35)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Over the last two months I've disclosed dozens of committed Supabase &lt;code&gt;service_role&lt;/code&gt; keys to maintainers. The workflow: find a committed key, verify it's live, email the owner with a 2-minute fix, move on. Most replies are warm: &lt;em&gt;"Wow, thank you so much — I'll get that rotated immediately."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That "immediately" kept nagging at me. What does it actually mean?&lt;/p&gt;

&lt;p&gt;So this week I ran a systematic re-verification pass: went back through my disclosure history and re-tested every project — fresh key extraction, fresh JWT decode, fresh live check against each project's REST endpoint (a single authenticated ping, no data reads, no logins).&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Out of 30 previously-disclosed repos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key still committed AND still live (HTTP 200)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The database is readable/writable by anyone who finds the repo — right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key committed, project dead (endpoint paused/deleted)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Exposure inactive, but the key wakes up if the project is ever restored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actually rotated and clean&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;The fix worked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;One in five "fixed" projects still had a working master key sitting in public code.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that worries me
&lt;/h2&gt;

&lt;p&gt;The worst case isn't the silence. It's this exact sequence, which happened twice this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I report a live &lt;code&gt;service_role&lt;/code&gt; key in a committed file.&lt;/li&gt;
&lt;li&gt;The owner replies: &lt;em&gt;"Wow, thank you — I'll get that rotated and removed immediately."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Days later I re-verify: &lt;strong&gt;the same key, same file, still HTTP 200.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rotation either never happened or happened to the wrong key — and the owner &lt;em&gt;believes they're safe&lt;/em&gt;. That false confidence is more dangerous than never replying, because nobody re-checks.&lt;/p&gt;

&lt;p&gt;One key I re-verified was issued in &lt;strong&gt;May&lt;/strong&gt; — reported in October, "fixed" two days before my re-check, still live at verification time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rotation fails (the ones I've seen)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The dashboard flow confuses "rotate service key" with "regenerate anon key."&lt;/strong&gt; Both buttons look similar; only one invalidates the leaked credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key lives in more than one place.&lt;/strong&gt; They remove it from &lt;code&gt;.env&lt;/code&gt;, forget &lt;code&gt;wrangler.jsonc&lt;/code&gt;, &lt;code&gt;.env.backup&lt;/code&gt;, the MCP config, the README setup section. The app keeps working — because the app still reads the &lt;em&gt;old&lt;/em&gt; key from somewhere else — so it &lt;em&gt;feels&lt;/em&gt; fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git history is treated as cleanup.&lt;/strong&gt; Deleting the file from HEAD doesn't remove the key from history. That's fine &lt;em&gt;only after&lt;/em&gt; rotation — but people do it in the wrong order and skip rotation entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 3-step check that would have caught all six
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decode the committed JWT.&lt;/strong&gt; The payload tells you &lt;code&gt;role&lt;/code&gt; and &lt;code&gt;ref&lt;/code&gt; in two seconds. &lt;code&gt;role: service_role&lt;/code&gt; + repo public = emergency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ping, don't assume.&lt;/strong&gt; One authenticated GET to &lt;code&gt;https://&amp;lt;ref&amp;gt;.supabase.co/rest/v1/&lt;/code&gt; with the key as both &lt;code&gt;apikey&lt;/code&gt; and &lt;code&gt;Authorization: Bearer&lt;/code&gt;. &lt;code&gt;200&lt;/code&gt; means the key is alive. &lt;code&gt;401&lt;/code&gt; means rotation worked. Nothing else matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate first, clean later.&lt;/strong&gt; Rotate the JWT secret in the dashboard (invalidates the old key instantly), &lt;em&gt;then&lt;/em&gt; update every location and commit. In that order, git history becomes harmless.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'm doing with this
&lt;/h2&gt;

&lt;p&gt;I now offer rotation verification as a fixed-scope service: I re-test every key I've ever reported for you and hand you a two-line verdict — &lt;em&gt;live&lt;/em&gt; or &lt;em&gt;dead&lt;/em&gt; — with evidence. It's the difference between "I think I rotated" and "it's dead, here's the 401."&lt;/p&gt;

&lt;p&gt;If you've ever committed a Supabase key: run step 2 above right now. It's one request. If you get a 200, rotate — the dashboard takes two minutes and the alternative is a stranger with full read/write on your database.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cenk Kurtoglu — &lt;a href="https://cenkkurtoglu.com" rel="noopener noreferrer"&gt;cenkkurtoglu.com&lt;/a&gt; · &lt;a href="https://github.com/cekuu35" rel="noopener noreferrer"&gt;github.com/cekuu35&lt;/a&gt;. I disclose leaked keys for free, permanently — no payment required for the report itself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>RLS is on, so why can another user see my data? 7 Supabase surfaces that leak anyway</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:48:03 +0000</pubDate>
      <link>https://dev.to/cekuu35/rls-is-on-so-why-can-another-user-see-my-data-7-supabase-surfaces-that-leak-anyway-5cil</link>
      <guid>https://dev.to/cekuu35/rls-is-on-so-why-can-another-user-see-my-data-7-supabase-surfaces-that-leak-anyway-5cil</guid>
      <description>&lt;p&gt;You turned on Row Level Security. You wrote a policy. You tested it with your own account and it worked.&lt;/p&gt;

&lt;p&gt;Then a user emails you: &lt;em&gt;"why can I see someone else's invoice?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the most common false-confidence trap in vibe-coded Supabase apps. &lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; is not a switch that makes data private. It makes a table &lt;em&gt;participate&lt;/em&gt; in RLS. Everything else — policies, views, grants, related objects — still decides who sees what. Any one gap and the data leaks while the dashboard shows a green "RLS enabled" badge.&lt;/p&gt;

&lt;p&gt;Here are the seven surfaces I check, in order, because they're where leaks actually hide. All queries below are read-only catalog reads you can run in the Supabase SQL editor.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. RLS enabled but zero (or empty) policies
&lt;/h2&gt;

&lt;p&gt;RLS with &lt;strong&gt;no policy&lt;/strong&gt; blocks everyone except &lt;code&gt;service_role&lt;/code&gt;. That's safe-but-broken, not a leak. The dangerous version is the opposite: RLS on, with a policy so broad it's effectively off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;qual&lt;/code&gt; column. If you see &lt;code&gt;true&lt;/code&gt;, or a role of &lt;code&gt;{public}&lt;/code&gt;, or &lt;code&gt;{anon}&lt;/code&gt;, that table is readable by anyone with your anon key — which is embedded in your client bundle and visible to the world.&lt;/p&gt;

&lt;p&gt;The one I see most: a "temporary" dev policy left in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the policy that ships to prod because it "worked"&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;"allow read"&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;profiles&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix direction:&lt;/strong&gt; scope by ownership, not by existence.&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;"own rows"&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;profiles&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. Cross-tenant leaks from a missing &lt;code&gt;WITH CHECK&lt;/code&gt; on writes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;USING&lt;/code&gt; filters what a user can &lt;strong&gt;read/update/delete&lt;/strong&gt;. &lt;code&gt;WITH CHECK&lt;/code&gt; filters what they can &lt;strong&gt;insert/change into&lt;/strong&gt;. A policy with &lt;code&gt;USING&lt;/code&gt; but no &lt;code&gt;WITH CHECK&lt;/code&gt; lets a user read only their rows — then &lt;em&gt;write&lt;/em&gt; a row into someone else's tenant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;using_expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;with_check&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; on an &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; policy, an attacker with the anon key can insert rows tagged with another &lt;code&gt;workspace_id&lt;/code&gt;/&lt;code&gt;org_id&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own"&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;documents&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;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;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Views bypass RLS by default
&lt;/h2&gt;

&lt;p&gt;This is the one that surprises people. A normal Postgres view runs with the &lt;strong&gt;owner's&lt;/strong&gt; privileges and does &lt;strong&gt;not&lt;/strong&gt; apply the base table's RLS. If you built a &lt;code&gt;public.user_summary&lt;/code&gt; view for convenience, RLS on &lt;code&gt;users&lt;/code&gt; may be irrelevant — the view quietly exposes everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;security_invoker&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
&lt;span class="k"&gt;left&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relowner&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Postgres 15+ (Supabase runs it) the fix is one line — make the view respect the caller's RLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_summary&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise you're relying on grants alone, which brings us to the next surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. &lt;code&gt;GRANT&lt;/code&gt; to &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; overrides intent
&lt;/h2&gt;

&lt;p&gt;RLS decides &lt;em&gt;which rows&lt;/em&gt;. Grants decide &lt;em&gt;whether the role can touch the object at all&lt;/em&gt;. A loose grant to &lt;code&gt;anon&lt;/code&gt; on a table with a broad policy is a double hole; a grant on a table you &lt;em&gt;thought&lt;/em&gt; was protected is often the actual vector.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role_table_grants&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;anon&lt;/code&gt; + &lt;code&gt;SELECT&lt;/code&gt; on anything containing emails, keys, or financial rows.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Storage buckets with public read
&lt;/h2&gt;

&lt;p&gt;RLS on your tables does nothing for Storage. Buckets have their own access model. A bucket set to &lt;strong&gt;public&lt;/strong&gt; serves every object to anyone who knows the URL — and object URLs are often loosely guarded in the app code.&lt;/p&gt;

&lt;p&gt;In the dashboard: Storage → bucket → check "Public bucket". If it's on and you store user uploads there, that's your leak. Verify with a quick script hitting the public object path with the anon key — if it 200s for a file you don't own, it's open.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Realtime broadcasts rows RLS can't stop
&lt;/h2&gt;

&lt;p&gt;Realtime starts from the anon/authenticated role and applies RLS &lt;strong&gt;for postgres_changes&lt;/strong&gt; — but only if you use Row Level Security correctly &lt;em&gt;and&lt;/em&gt; the table is in the publication. Misconfigured, a client subscribes to a table and receives change events for rows it should never see.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_publication_tables&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;pubname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supabase_realtime'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a sensitive table is published, confirm the subscribing user passes your SELECT policy for &lt;strong&gt;every&lt;/strong&gt; row the channel delivers — not just the rows you intended to show.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions re-open the door
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function runs as its owner — often a superuser-ish role that RLS doesn't restrict. If such a function (common in trigger helpers, e.g. an &lt;code&gt;is_admin()&lt;/code&gt; check) is &lt;code&gt;EXECUTE&lt;/code&gt;-able by &lt;code&gt;anon&lt;/code&gt;, an attacker can call it directly and get owner-level access.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;security_definer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_get_function_identity_arguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proowner&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix direction:&lt;/strong&gt; &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions should &lt;code&gt;set search_path = ''&lt;/code&gt;, validate &lt;code&gt;auth.uid()&lt;/code&gt; inside, and be revoked from &lt;code&gt;anon&lt;/code&gt; unless truly needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every one of these leaks shares a shape: &lt;strong&gt;RLS was enabled, so everyone assumed the work was done.&lt;/strong&gt; The check isn't "is RLS on", it's "for each object a low-privilege role can reach, what does it actually return?"&lt;/p&gt;

&lt;p&gt;If you want the structured version, I keep these as seven commented SQL audit files (RLS coverage, policies, write checks, grants, bypass paths, Storage, Realtime, plus a safe &lt;code&gt;BEGIN...ROLLBACK&lt;/code&gt; probe file) with a 60-check manual pass and an optional two-user isolation harness. The catalog queries above are the first pass; the kit is the full sweep with the report/remediation templates.&lt;/p&gt;

&lt;p&gt;More detail and the exact file list: &lt;a href="https://rls.cenkkurtoglu.com/?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=rls7surfaces" rel="noopener noreferrer"&gt;rls.cenkkurtoglu.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're mid-launch and just want a second pair of eyes before real users sign up, that's what I do — read-only review of source, schema and policies.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Supabase Security Checklist to Run Before You Launch</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:24:01 +0000</pubDate>
      <link>https://dev.to/cekuu35/the-supabase-security-checklist-to-run-before-you-launch-4e18</link>
      <guid>https://dev.to/cekuu35/the-supabase-security-checklist-to-run-before-you-launch-4e18</guid>
      <description>&lt;p&gt;If you built something on Supabase with a lot of AI help and you're about to ship it, this checklist is for you. Supabase is secure by default in the sense that the tools are all there — but "vibe coding" tends to skip the boring parts, and the boring parts are exactly where data leaks live. Every item below is a one-line check plus the fix. Run them in order. It's maybe 30 minutes, and it's the difference between a launch and an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RLS is enabled on every table in the &lt;code&gt;public&lt;/code&gt; schema
&lt;/h2&gt;

&lt;p&gt;The single most common Supabase mistake: a table exists, the anon key can reach it over PostgREST, and Row Level Security was never turned on. With RLS off, the table's GRANTs are the only gate — and because Supabase exposes the &lt;code&gt;public&lt;/code&gt; schema to the &lt;code&gt;anon&lt;/code&gt; role by default, that means anyone holding your (public) anon key can read it.&lt;/p&gt;

&lt;p&gt;Check — run this in the SQL editor to list every public table with RLS &lt;strong&gt;off&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any rows returned are exposed. Fix each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&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;your_table&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;Enabling RLS with no policies is deny-all — a safe default. Now add the policy you actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every table has owner-scoped policies (not &lt;code&gt;using (true)&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Enabling RLS isn't enough if your policy waves everyone through. The classic leak is &lt;code&gt;using (true)&lt;/code&gt; with no &lt;code&gt;to&lt;/code&gt; clause. A &lt;code&gt;SELECT&lt;/code&gt; policy like that is world-readable, &lt;code&gt;anon&lt;/code&gt; included. Two facts to keep straight: a policy with &lt;strong&gt;no &lt;code&gt;TO&lt;/code&gt; clause applies to all roles&lt;/strong&gt;, and for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;auth.uid()&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; — so any check that compares against it silently fails open or closed depending on how you wrote it.&lt;/p&gt;

&lt;p&gt;Check — list every policy and read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;qual&lt;/code&gt; is the &lt;code&gt;USING&lt;/code&gt; expression; &lt;code&gt;with_check&lt;/code&gt; is the &lt;code&gt;WITH CHECK&lt;/code&gt; expression. A bare &lt;code&gt;true&lt;/code&gt; in &lt;code&gt;qual&lt;/code&gt; on a &lt;code&gt;SELECT&lt;/code&gt; policy is world-readable. (It's only world-&lt;em&gt;writable&lt;/em&gt; if a permissive &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;ALL&lt;/code&gt; policy also allows it — reads and writes are separate policies.) Fix with owner scoping:&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;-- users can only read their own rows&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;"own rows are selectable"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- and only insert rows they own&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;"insert own rows"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things people get wrong here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt; vs &lt;code&gt;WITH CHECK&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;USING&lt;/code&gt; filters which existing rows a role can see, and which it may update or delete. &lt;code&gt;WITH CHECK&lt;/code&gt; validates rows being written. An &lt;code&gt;INSERT&lt;/code&gt; policy has &lt;code&gt;WITH CHECK&lt;/code&gt; only — there are no existing rows to filter. &lt;code&gt;UPDATE&lt;/code&gt; uses both. If you protect reads but forget &lt;code&gt;WITH CHECK&lt;/code&gt; on writes, users can insert or update rows they shouldn't own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A policy &lt;em&gt;and&lt;/em&gt; a grant.&lt;/strong&gt; A role needs a matching policy &lt;em&gt;and&lt;/em&gt; the table GRANT. Multiple permissive policies OR together; restrictive policies AND. If access feels "too open," look for a second permissive policy widening things.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. You're using the right key in the right place
&lt;/h2&gt;

&lt;p&gt;Every Supabase project has two keys, and confusing them is catastrophic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;anon / publishable&lt;/strong&gt; (&lt;code&gt;role: anon&lt;/code&gt;, new format &lt;code&gt;sb_publishable_...&lt;/code&gt;) is &lt;strong&gt;public by design.&lt;/strong&gt; It's meant to ship in your browser bundle. It is not a secret, and it does not need rotating. RLS is what protects your data when this key is used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;service_role&lt;/strong&gt; (&lt;code&gt;role: service_role&lt;/code&gt;, new format &lt;code&gt;sb_secret_...&lt;/code&gt;) is a &lt;strong&gt;server-only secret that bypasses RLS entirely&lt;/strong&gt; — full read/write/delete across every table, plus Storage. It must never appear in a browser, a public repo, or a client config file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not sure which is which? Decode the JWT payload — the middle segment, base64URL — and read the role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# paste your key; this prints the payload&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PASTE_KEY'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"role":"anon"&lt;/code&gt; is the browser key. &lt;code&gt;"role":"service_role"&lt;/code&gt; must never leave your server.&lt;/p&gt;

&lt;p&gt;The trap in frontend frameworks: any env var prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, &lt;code&gt;VITE_&lt;/code&gt;, or &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; is &lt;strong&gt;inlined into the client bundle at build time.&lt;/strong&gt; That's correct and safe for the anon key and project URL. Putting the &lt;code&gt;service_role&lt;/code&gt; key behind one of those prefixes ships your master key to every visitor. Check your env files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_'&lt;/span&gt; .env&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'service\|secret'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything returned is a bug. The service_role key belongs only in server-side env — API routes, edge functions, backend — never behind a public prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. No secrets in the repo — or in git history
&lt;/h2&gt;

&lt;p&gt;Deleting a secret file doesn't help if you already committed it once. &lt;strong&gt;A committed secret lives in git history forever.&lt;/strong&gt; AI tooling makes this worse by scattering config files people don't think to check.&lt;/p&gt;

&lt;p&gt;Check the working tree, including the easy-to-miss ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'service_role|sb_secret_|eyJ[A-Za-z0-9_-]{20,}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.json'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.env*'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.ts'&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# the ones vibe-coders forget:&lt;/span&gt;
git ls-files | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'\.env|\.claude/settings\.local\.json|\.cursor/mcp\.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.claude/settings.local.json&lt;/code&gt;, &lt;code&gt;.cursor/mcp.json&lt;/code&gt;, and &lt;code&gt;.env.local&lt;/code&gt; love to hold live keys and DB connection strings. Then check history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'.env*'&lt;/span&gt; &lt;span class="s1"&gt;'**/mcp.json'&lt;/span&gt; &lt;span class="s1"&gt;'.claude/settings.local.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate first.&lt;/strong&gt; If a &lt;code&gt;service_role&lt;/code&gt; key (or DB password) was ever committed, treat it as compromised. Regenerate it in the dashboard under Project Settings → API. Deleting the file does not un-leak it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge history&lt;/strong&gt; with &lt;code&gt;git filter-repo&lt;/code&gt; (or BFG), then force-push:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; .env.local &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Add the patterns to &lt;code&gt;.gitignore&lt;/code&gt; so it can't recur:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env*
.claude/settings.local.json
.cursor/mcp.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters: rotate before you spend time rewriting history, because the old key is public the moment the repo is.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Storage buckets have real policies
&lt;/h2&gt;

&lt;p&gt;Storage is Postgres too — access lives in &lt;code&gt;storage.objects&lt;/code&gt; and obeys RLS. A public bucket means the files are readable by URL to anyone. A private bucket with no policy means no one can read it, which is usually not what you shipped either.&lt;/p&gt;

&lt;p&gt;Check your buckets and their object policies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&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;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'storage'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'objects'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide per bucket: are these files meant to be world-readable (avatars, marketing images) or private (invoices, uploads)? For an owner-scoped private bucket, a common pattern keys access to a per-user folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users read own folder"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&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;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user-uploads'&lt;/span&gt;
  &lt;span class="k"&gt;and&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="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&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;h2&gt;
  
  
  6. Do a real self-audit before you trust the checklist
&lt;/h2&gt;

&lt;p&gt;Reading policies by hand is exactly where over-confidence creeps in — a policy can &lt;em&gt;look&lt;/em&gt; scoped and still leak because of a second permissive policy or a missing &lt;code&gt;TO&lt;/code&gt;. So verify empirically.&lt;/p&gt;

&lt;p&gt;I keep a small free repo for this: &lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/a&gt;. It's a minimal reproduction of the classic RLS leak plus read-only audit SQL you can paste into your own SQL editor to list tables without RLS, policies with no &lt;code&gt;TO&lt;/code&gt; clause, and &lt;code&gt;using (true)&lt;/code&gt; permissive policies in one shot. It changes nothing in your database — it just tells you where you stand.&lt;/p&gt;

&lt;p&gt;The most honest test, though, is to hit your project the way an attacker would: with nothing but the public anon key.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;If that returns rows you expected to be private, RLS is not doing what you think. Fix it before launch, not after.&lt;/p&gt;




&lt;p&gt;Run all six and you've closed the failure modes behind almost every "Supabase leaked my data" post-mortem: RLS off, &lt;code&gt;using (true)&lt;/code&gt;, the wrong key in the bundle, and a secret buried in git history.&lt;/p&gt;

&lt;p&gt;If you'd rather not assemble this by hand, I packaged the whole thing — the audit queries, owner-scoped policy templates for the common table shapes, the Storage patterns, and a printable pre-launch checklist — into a $29 RLS Audit Kit. The free demo repo above is genuinely enough to secure your project; the kit just saves you the afternoon of writing the SQL yourself. Either way, ship it locked down.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>NEXT_PUBLIC_ and Supabase: Which Env Variables Are Safe to Expose?</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:23:15 +0000</pubDate>
      <link>https://dev.to/cekuu35/nextpublic-and-supabase-which-env-variables-are-safe-to-expose-43p4</link>
      <guid>https://dev.to/cekuu35/nextpublic-and-supabase-which-env-variables-are-safe-to-expose-43p4</guid>
      <description>&lt;p&gt;If you've ever stared at a &lt;code&gt;.env.local&lt;/code&gt; full of Supabase keys and wondered &lt;em&gt;"wait, which of these is safe to ship to the browser?"&lt;/em&gt; — you're not alone. It's one of the most common questions I see, and getting it wrong ranges from "totally fine" to "someone just dumped your entire users table." Let's clear it up for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule that explains everything
&lt;/h2&gt;

&lt;p&gt;In Next.js, any environment variable prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; gets &lt;strong&gt;inlined into the JavaScript bundle at build time&lt;/strong&gt;. Same story for &lt;code&gt;VITE_&lt;/code&gt; in Vite apps and &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; in Expo. The prefix is not a suggestion — it's an instruction to the bundler: &lt;em&gt;copy this value into code that runs in the user's browser.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the question "is this variable safe to expose?" is really "is it safe for this value to sit in plain text inside the client bundle, readable by anyone who opens DevTools?"&lt;/p&gt;

&lt;p&gt;For Supabase, the answer depends entirely on &lt;strong&gt;which key&lt;/strong&gt; you're talking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Supabase project has two keys, and they are not equals
&lt;/h2&gt;

&lt;p&gt;Open your project's API settings and you get two keys. They look similar. They could not be more different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The &lt;code&gt;anon&lt;/code&gt; key&lt;/strong&gt; (newer projects call this the &lt;em&gt;publishable&lt;/em&gt; key, formatted &lt;code&gt;sb_publishable_...&lt;/code&gt;). Its JWT carries &lt;code&gt;"role": "anon"&lt;/code&gt;. This key is &lt;strong&gt;public by design.&lt;/strong&gt; It's meant to ship in the browser. It is &lt;em&gt;not&lt;/em&gt; a secret, and you do &lt;em&gt;not&lt;/em&gt; need to rotate it if it "leaks" — because it was never hidden in the first place. What keeps your data safe when this key is used is &lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt;: the anon key can only do what your RLS policies allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;service_role&lt;/code&gt; key&lt;/strong&gt; (formatted &lt;code&gt;sb_secret_...&lt;/code&gt; on newer projects). Its JWT carries &lt;code&gt;"role": "service_role"&lt;/code&gt;. This key &lt;strong&gt;bypasses Row Level Security entirely.&lt;/strong&gt; Full read, write, and delete on every table, plus Storage. It exists for trusted server environments only. It must &lt;strong&gt;never&lt;/strong&gt; appear in a browser bundle, a public repo, or any client-side config.&lt;/p&gt;

&lt;p&gt;Here's a reliable way to tell any Supabase JWT apart — decode the middle segment (it's base64url) and read the role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# paste your key in place of $KEY&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'_-'&lt;/span&gt; &lt;span class="s1"&gt;'/+'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'"role":"[a-z_]*"'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; "role":"anon"          -&amp;gt; public, safe for the browser&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; "role":"service_role"  -&amp;gt; secret, server-only, never public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The &lt;code&gt;tr '_-' '/+'&lt;/code&gt; converts base64url to standard base64 so &lt;code&gt;base64 -d&lt;/code&gt; can read it.) The newer &lt;code&gt;sb_publishable_&lt;/code&gt; / &lt;code&gt;sb_secret_&lt;/code&gt; keys aren't JWTs, but the naming makes the distinction obvious — &lt;code&gt;secret&lt;/code&gt; means secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, the safe list
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Safe to expose (belongs in &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://xxxx.supabase.co
&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sb_publishable_xxx   &lt;span class="c"&gt;# or the legacy anon JWT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project URL is public — it's just an HTTPS endpoint. The anon key is public by design. Ship them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never public (no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, ever):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sb_secret_xxx
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://postgres:password@db.xxxx.supabase.co:5432/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt; contains your database password and gives direct Postgres access. That connection bypasses RLS the same way &lt;code&gt;service_role&lt;/code&gt; does — the &lt;code&gt;postgres&lt;/code&gt; role is not subject to your policies. Both stay strictly server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping service_role strictly server-side
&lt;/h2&gt;

&lt;p&gt;The safe pattern: the browser talks to Supabase with the anon key (protected by RLS), and anything that genuinely needs to bypass RLS runs on a server you control. Next.js gives you three good homes for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route handlers&lt;/strong&gt; (&lt;code&gt;app/api/.../route.ts&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/admin-report/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// No NEXT_PUBLIC_ prefix -&amp;gt; stays on the server, never bundled&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// runs server-side only; safe to use the service_role client here&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;reports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;Server Actions&lt;/strong&gt; work the same way — they only ever execute on the server, so reading &lt;code&gt;process.env.SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; there is fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deleteAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&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;Supabase Edge Functions&lt;/strong&gt; get the service_role key from their own secret store (&lt;code&gt;Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')&lt;/code&gt;), completely outside your frontend build — a great home for privileged jobs.&lt;/p&gt;

&lt;p&gt;The mental model: if a file has &lt;code&gt;'use client'&lt;/code&gt; at the top, or gets imported by one, assume everything in it is public. Never read a secret there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch the mistake before it ships
&lt;/h2&gt;

&lt;p&gt;The classic disaster is one character of muscle memory: someone types &lt;code&gt;NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; because every other Supabase var starts that way. Now your RLS-bypassing key is inlined into the client bundle.&lt;/p&gt;

&lt;p&gt;Grep for it. Add this to CI or a pre-commit hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fails if any NEXT_PUBLIC_ / VITE_ / EXPO_PUBLIC_ var references service_role&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_)[A-Z_]*SERVICE_ROLE'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FATAL: service_role key exposed via a public env prefix"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: no service_role key behind a public prefix"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's also worth scanning the built output itself — a secret can leak through a hardcoded string even without the prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# after `next build`&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'sb_secret_|service_role'&lt;/span&gt; .next/static &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FATAL: secret found in client bundle"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"clean"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  If a secret already leaked
&lt;/h2&gt;

&lt;p&gt;Two things, in order. First, &lt;strong&gt;rotate the key&lt;/strong&gt; in your Supabase dashboard immediately — a leaked service_role key is a live master key until you do. Second, remember that deleting the file in a new commit is &lt;strong&gt;not enough&lt;/strong&gt;: the value lives in your git history forever, and anyone can &lt;code&gt;git log -p&lt;/code&gt; it back. You have to purge history with &lt;code&gt;git filter-repo&lt;/code&gt; (or the BFG), then force-push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; .env.local &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotate &lt;em&gt;and&lt;/em&gt; purge. One without the other leaves you exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people forget: the anon key is only as safe as your RLS
&lt;/h2&gt;

&lt;p&gt;Shipping the anon key is safe &lt;em&gt;only because RLS stands between it and your data.&lt;/em&gt; If a table has RLS disabled, or has a policy like &lt;code&gt;USING (true)&lt;/code&gt; with no &lt;code&gt;TO&lt;/code&gt; clause, then that "public" anon key can read every row — for every user. A &lt;code&gt;SELECT&lt;/code&gt; policy of &lt;code&gt;USING (true)&lt;/code&gt; is genuinely world-readable. (It's only world-&lt;em&gt;writable&lt;/em&gt; if a permissive &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;ALL&lt;/code&gt; policy exists too — but world-readable is already a breach for most apps.)&lt;/p&gt;

&lt;p&gt;Remember the anatomy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt; filters which existing rows a role can see, and which it may update or delete.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt; validates rows being inserted or updated; &lt;code&gt;INSERT&lt;/code&gt; policies have &lt;code&gt;WITH CHECK&lt;/code&gt; only.&lt;/li&gt;
&lt;li&gt;A role needs &lt;strong&gt;both&lt;/strong&gt; a matching policy &lt;strong&gt;and&lt;/strong&gt; the underlying table &lt;code&gt;GRANT&lt;/code&gt;. Permissive policies OR together; restrictive ones AND.&lt;/li&gt;
&lt;li&gt;A policy with no &lt;code&gt;TO&lt;/code&gt; clause applies to &lt;strong&gt;all&lt;/strong&gt; roles, &lt;code&gt;anon&lt;/code&gt; included.&lt;/li&gt;
&lt;li&gt;For an anonymous visitor, &lt;code&gt;auth.uid()&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; — so any policy that quietly assumes a logged-in user needs testing from the anon perspective.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to actually check your own project rather than take my word for it, I put together a free, read-only audit: &lt;strong&gt;&lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/a&gt;&lt;/strong&gt;. It's a tiny reproducible leak plus a set of SQL queries you can paste into the Supabase SQL editor to list every table with RLS off and every policy that resolves to &lt;code&gt;true&lt;/code&gt; for &lt;code&gt;anon&lt;/code&gt;. Run it against a staging project and see what falls out — it's usually one or two tables you forgot about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/code&gt;: public by design, ship them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; and &lt;code&gt;DATABASE_URL&lt;/code&gt;: server-only, no public prefix, ever.&lt;/li&gt;
&lt;li&gt;Decode the JWT role when you're unsure; &lt;code&gt;git grep&lt;/code&gt; for the prefix mistake in CI.&lt;/li&gt;
&lt;li&gt;The anon key's safety &lt;em&gt;is&lt;/em&gt; your RLS — so audit your policies, don't assume them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather not hand-roll the whole audit, I also maintain a &lt;strong&gt;$29 RLS Audit Kit&lt;/strong&gt; — the same read-only checks packaged with a policy checklist and a few "here's the fix" templates for the leaks it turns up. Totally optional; the free demo above will already get you most of the way. Either way, go check your policies today — future-you will be glad you did.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>You Committed .env to GitHub: What to Rotate (Supabase + Next.js)</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:22:29 +0000</pubDate>
      <link>https://dev.to/cekuu35/you-committed-env-to-github-what-to-rotate-supabase-nextjs-2phc</link>
      <guid>https://dev.to/cekuu35/you-committed-env-to-github-what-to-rotate-supabase-nextjs-2phc</guid>
      <description>&lt;p&gt;You pushed, then your stomach dropped: &lt;code&gt;.env&lt;/code&gt; is in the commit. Maybe a bot already emailed you. Take a breath — this is recoverable, and panicking into random &lt;code&gt;git&lt;/code&gt; commands usually makes it worse.&lt;/p&gt;

&lt;p&gt;The single most important thing to understand: &lt;strong&gt;deleting the file does not fix anything.&lt;/strong&gt; Once a secret is in git history, assume it is public forever. The real fix is &lt;strong&gt;rotation&lt;/strong&gt; — making the leaked value useless by replacing it upstream. Purging history is cleanup you do &lt;em&gt;after&lt;/em&gt; rotating, not instead of it.&lt;/p&gt;

&lt;p&gt;Let's go value by value through a typical Supabase + Next.js &lt;code&gt;.env&lt;/code&gt;, sort out what's actually a secret, and rotate only what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: which of these is actually secret?
&lt;/h2&gt;

&lt;p&gt;A typical &lt;code&gt;.env&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://abcdxyz.supabase.co
&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eyJhbGciOi...        &lt;span class="c"&gt;# role: anon&lt;/span&gt;
&lt;span class="nv"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eyJhbGciOi...            &lt;span class="c"&gt;# role: service_role&lt;/span&gt;
&lt;span class="nv"&gt;SUPABASE_JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;super-secret-signing-key
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://postgres:PASSWORD@db.abcdxyz.supabase.co:5432/postgres
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_live_...
&lt;span class="nv"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;re_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part that saves you hours of unnecessary panic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe by design — do NOT need rotating:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/code&gt; — your project URL is public. It's in every network request the browser makes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/code&gt; — the anon (publishable) key is &lt;strong&gt;public by design&lt;/strong&gt;. It ships in your client bundle on purpose. Any &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variable is inlined into the browser JavaScript at build time, so it was never a secret to begin with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The anon key's role is &lt;code&gt;anon&lt;/code&gt;. It has &lt;strong&gt;no special power on its own&lt;/strong&gt; — it's just an identity meaning "an unauthenticated (or logged-in) user of this project." What protects your data when that key is used is &lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt;. If your tables have RLS enabled with correct policies, a leaked anon key is a non-event. If they don't, that key was already exposed to every visitor of your site — the git commit changed nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actually secret — rotate every one of these:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SUPABASE_JWT_SECRET&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DATABASE_URL&lt;/code&gt; (the Postgres password inside it)&lt;/li&gt;
&lt;li&gt;Every third-party key: Stripe, Resend, OpenAI, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service_role key is the scary one. Its role is &lt;code&gt;service_role&lt;/code&gt;, and it &lt;strong&gt;bypasses RLS entirely&lt;/strong&gt; — full read, write, and delete on every table, plus Storage. It's a server-only secret. It must never appear in a browser, a public repo, or anything with a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix. If it leaked, treat it as a full database breach until rotated.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to tell the two keys apart
&lt;/h3&gt;

&lt;p&gt;Both are JWTs, so they look identical at a glance. Decode the middle segment (base64URL) and read the &lt;code&gt;role&lt;/code&gt; claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# paste your key in place of PASTE_KEY&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PASTE_KEY'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null | python &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see either &lt;code&gt;"role": "anon"&lt;/code&gt; (safe, public) or &lt;code&gt;"role": "service_role"&lt;/code&gt; (secret, rotate now). Newer Supabase projects use clearer prefixes instead of JWTs: &lt;code&gt;sb_publishable_...&lt;/code&gt; (public) vs &lt;code&gt;sb_secret_...&lt;/code&gt; (secret). The prefix tells you everything — no decoding needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotating each secret
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Supabase keys
&lt;/h3&gt;

&lt;p&gt;In the dashboard, go to &lt;strong&gt;Project Settings → API Keys&lt;/strong&gt;. How rotation works depends on which key system your project uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New keys (&lt;code&gt;sb_publishable_&lt;/code&gt; / &lt;code&gt;sb_secret_&lt;/code&gt;):&lt;/strong&gt; these are independent of each other and of the JWT secret. Revoke the leaked &lt;code&gt;sb_secret_&lt;/code&gt; key and create a new one; the publishable key is unaffected because it wasn't a secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy JWT keys:&lt;/strong&gt; both the anon and service_role keys are JWTs signed by your project's &lt;strong&gt;JWT secret&lt;/strong&gt;. Rolling that secret regenerates &lt;em&gt;both&lt;/em&gt; keys at once, because both are signatures over it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, after rotating the secret:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy the &lt;strong&gt;new&lt;/strong&gt; service_role / &lt;code&gt;sb_secret_&lt;/code&gt; key into your server environment (Vercel project env vars, &lt;code&gt;.env.local&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;If you rolled a legacy JWT secret, also grab the regenerated anon key and redeploy so the fresh key ships in your bundle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One consequence to expect on the legacy path: rolling the JWT secret invalidates every JWT signed with the old one, which &lt;strong&gt;logs out all existing user sessions&lt;/strong&gt;. That's the correct, safe outcome after a leak — do it anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database password
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Project Settings → Database → Reset database password.&lt;/strong&gt; This changes the password embedded in your &lt;code&gt;DATABASE_URL&lt;/code&gt; / connection string. Update that string everywhere it lives (Vercel, CI secrets, local &lt;code&gt;.env.local&lt;/code&gt;, migration tooling). Anything still using the old password starts failing — that's your checklist of places to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Third-party keys
&lt;/h3&gt;

&lt;p&gt;Each provider has its own flow, but the pattern is identical: generate a new key, deploy it, then &lt;strong&gt;revoke the old one&lt;/strong&gt;. Don't skip the revoke — a new key does not disable the leaked one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe:&lt;/strong&gt; Developers → API keys → roll the leaked secret key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend / OpenAI / etc.:&lt;/strong&gt; create new key, swap in env, delete old.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Now purge git history
&lt;/h2&gt;

&lt;p&gt;Rotation makes the leaked values worthless. Purging history is about hygiene and not re-leaking on the next clone. Deleting the file in a new commit is &lt;strong&gt;not&lt;/strong&gt; enough — the old commit still contains it.&lt;/p&gt;

&lt;p&gt;The clean tool is &lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;&lt;code&gt;git filter-repo&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo

&lt;span class="c"&gt;# remove the file from all of history&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; .env &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with &lt;strong&gt;BFG&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bfg &lt;span class="nt"&gt;--delete-files&lt;/span&gt; .env
git reflog expire &lt;span class="nt"&gt;--expire&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git gc &lt;span class="nt"&gt;--prune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--aggressive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then force-push the rewritten history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest caveats: force-pushing rewrites history for every collaborator (they'll need to re-clone or reset), and &lt;strong&gt;GitHub caches commits&lt;/strong&gt; — forks and cached commit views can retain the blob even after your push. That's exactly why rotation is the real fix and history-purging is secondary. Then add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; so this can't recur:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env.local"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
git &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;--cached&lt;/span&gt; .env      &lt;span class="c"&gt;# stop tracking without deleting your local copy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part everyone skips: is your RLS actually protecting you?
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable truth. If a leaked anon key genuinely exposes your data, the anon key wasn't the vulnerability — &lt;strong&gt;your RLS was.&lt;/strong&gt; That key is public to every visitor regardless of any git leak.&lt;/p&gt;

&lt;p&gt;A few things worth checking on every table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RLS is enabled&lt;/strong&gt; on the table. Enabling it with no policies denies all access through the API — that's fail-safe, not broken.&lt;/li&gt;
&lt;li&gt;A role needs &lt;strong&gt;both&lt;/strong&gt; a matching policy &lt;strong&gt;and&lt;/strong&gt; the underlying table &lt;code&gt;GRANT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A policy with &lt;strong&gt;no &lt;code&gt;TO&lt;/code&gt; clause applies to every role, including &lt;code&gt;anon&lt;/code&gt;.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USING (true)&lt;/code&gt; on a &lt;code&gt;SELECT&lt;/code&gt; policy makes the table &lt;strong&gt;world-readable&lt;/strong&gt;. It's only world-&lt;em&gt;writable&lt;/em&gt; if a permissive &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;ALL&lt;/code&gt; policy also exists (&lt;code&gt;INSERT&lt;/code&gt; policies gate rows with &lt;code&gt;WITH CHECK&lt;/code&gt;, not &lt;code&gt;USING&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;For an anonymous request, &lt;code&gt;auth.uid()&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; — so &lt;code&gt;USING (auth.uid() = user_id)&lt;/code&gt; correctly returns nothing to &lt;code&gt;anon&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick audit of what's exposed:&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;-- tables with RLS OFF (guarded only by GRANTs, no policy gate)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;relnamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- every policy, so you can eyeball no-TO and USING(true)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If reading those results makes you nervous, I put together a free, read-only repro and audit kit here: &lt;strong&gt;&lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/a&gt;&lt;/strong&gt;. It has a deliberately leaky schema plus copy-paste SQL that flags world-readable tables and no-&lt;code&gt;TO&lt;/code&gt; policies, so you can see exactly what an anon key can reach in your own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-second recap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Don't panic, don't just delete the file.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anon key + project URL + &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; vars:&lt;/strong&gt; safe, no rotation needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;service_role, JWT secret, DB password, third-party keys:&lt;/strong&gt; rotate now, revoke the old ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge history&lt;/strong&gt; with &lt;code&gt;filter-repo&lt;/code&gt;/BFG and force-push — but only &lt;em&gt;after&lt;/em&gt; rotating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your RLS&lt;/strong&gt;, because that's what actually stands between the public anon key and your data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to go deeper than the free audit — a full policy-by-policy checklist, the common false-positive traps, and fix templates — I keep a &lt;strong&gt;$29 RLS Audit Kit&lt;/strong&gt; that packages it all up. Totally optional; the free demo repo above is enough to check and fix most projects on your own. Either way: rotate first, breathe second, and get RLS right so the next leak is a shrug instead of a scramble.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>security</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Test Your Supabase RLS Policies Locally: A Free SQL Harness</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:21:42 +0000</pubDate>
      <link>https://dev.to/cekuu35/test-your-supabase-rls-policies-locally-a-free-sql-harness-2g1j</link>
      <guid>https://dev.to/cekuu35/test-your-supabase-rls-policies-locally-a-free-sql-harness-2g1j</guid>
      <description>&lt;p&gt;Row Level Security is the thing standing between your Supabase tables and the whole internet. Your anon key ships in the browser bundle on purpose — that's fine, that's by design, it isn't a secret — but it means any row your policies fail to lock down is a row a stranger can read with &lt;code&gt;curl&lt;/code&gt;. So the real question isn't "do I have RLS on?" It's "do my policies actually do what I think they do?"&lt;/p&gt;

&lt;p&gt;The good news: you can answer that in a local Postgres, deterministically, without touching production or hitting a rate limit. The trick is that Supabase's &lt;code&gt;auth.uid()&lt;/code&gt; and the &lt;code&gt;anon&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt; roles are just Postgres primitives you can reproduce. This is a walkthrough of a small harness that impersonates &lt;code&gt;anon&lt;/code&gt; and any logged-in user right in &lt;code&gt;psql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Everything here is packaged as a ready-to-run repo: &lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;. Clone it if you'd rather run than copy-paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Supabase auth maps to plain Postgres
&lt;/h2&gt;

&lt;p&gt;Two facts unlock the whole thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When PostgREST handles a request, it does &lt;code&gt;SET ROLE anon&lt;/code&gt; (or &lt;code&gt;authenticated&lt;/code&gt;) and stuffs the decoded JWT into a session setting called &lt;code&gt;request.jwt.claims&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auth.uid()&lt;/code&gt; is just a SQL function that reads &lt;code&gt;sub&lt;/code&gt; out of those claims.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;service_role&lt;/code&gt; key is different in kind — it's a &lt;strong&gt;server secret that bypasses RLS entirely&lt;/strong&gt; (full read, write, delete, plus Storage). It never belongs in a browser, a public repo, or a client config, and it's not what we're testing here. (Reflex check: a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; / &lt;code&gt;VITE_&lt;/code&gt; / &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; prefix inlines a value straight into the client bundle — safe for the anon key and project URL, catastrophic for &lt;code&gt;service_role&lt;/code&gt;. Tell the two keys apart by decoding the JWT's middle segment as base64URL: &lt;code&gt;"role":"anon"&lt;/code&gt; vs &lt;code&gt;"role":"service_role"&lt;/code&gt; — or by the newer &lt;code&gt;sb_publishable_&lt;/code&gt; vs &lt;code&gt;sb_secret_&lt;/code&gt; prefixes.) We're testing the role that &lt;em&gt;is&lt;/em&gt; exposed: &lt;code&gt;anon&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So to reproduce a request locally, we set the role and set the claims. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — a minimal shim
&lt;/h2&gt;

&lt;p&gt;If you run the full stack with &lt;code&gt;supabase start&lt;/code&gt;, the &lt;code&gt;auth&lt;/code&gt; schema and roles already exist and you can skip this. On a bare Postgres, create them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- roles PostgREST switches into&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&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="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt; &lt;span class="n"&gt;nologin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&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="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="n"&gt;nologin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- the same auth.uid() Supabase gives you&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;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;returns&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;stable&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="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;true&lt;/code&gt; argument to &lt;code&gt;current_setting&lt;/code&gt; means "return NULL instead of erroring if it's unset" — which is exactly the anonymous case. &lt;strong&gt;For &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;auth.uid()&lt;/code&gt; is NULL.&lt;/strong&gt; Keep that in mind: any policy written as &lt;code&gt;auth.uid() = user_id&lt;/code&gt; silently denies the anon user, because &lt;code&gt;NULL = anything&lt;/code&gt; is never true. That's usually what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — a table with a policy to test
&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&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;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="n"&gt;title&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;body&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&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="c1"&gt;-- owners can read their own rows&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"owner can read"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&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;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;to authenticated&lt;/code&gt;. A policy with &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;TO&lt;/code&gt; clause applies to &lt;em&gt;all&lt;/em&gt; roles, including &lt;code&gt;anon&lt;/code&gt; — a common way to leak data by accident. Being explicit about the role is half the battle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — impersonate anon and a real user
&lt;/h2&gt;

&lt;p&gt;Wrap each check in a transaction with &lt;code&gt;SET LOCAL&lt;/code&gt; so the role and claims reset automatically on &lt;code&gt;ROLLBACK&lt;/code&gt;. That keeps tests isolated and non-destructive.&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;-- as an anonymous visitor (no JWT)&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;anon_sees&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- as a specific logged-in user&lt;/span&gt;
&lt;span class="k"&gt;begin&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;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'11111111-1111-1111-1111-111111111111'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&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;true&lt;/span&gt;                       &lt;span class="c1"&gt;-- is_local: scoped to this transaction&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&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;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;-- only their rows&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the claims &lt;em&gt;before&lt;/em&gt; switching role, then switch. Now you can assert concrete numbers. With the policy above, &lt;code&gt;anon_sees&lt;/code&gt; should be &lt;code&gt;0&lt;/code&gt;, and the authenticated block should return only rows whose &lt;code&gt;owner_id&lt;/code&gt; matches that &lt;code&gt;sub&lt;/code&gt;. If &lt;code&gt;anon_sees&lt;/code&gt; is anything but zero, you have a leak — go look at your &lt;code&gt;TO&lt;/code&gt; clauses and &lt;code&gt;USING&lt;/code&gt; expressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two mistakes worth internalizing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt; vs &lt;code&gt;WITH CHECK&lt;/code&gt; are not interchangeable.&lt;/strong&gt; &lt;code&gt;USING&lt;/code&gt; decides which &lt;em&gt;existing&lt;/em&gt; rows a role can see, and which it's allowed to scan for &lt;code&gt;UPDATE&lt;/code&gt; / &lt;code&gt;DELETE&lt;/code&gt;. &lt;code&gt;WITH CHECK&lt;/code&gt; validates the &lt;em&gt;new&lt;/em&gt; row values on &lt;code&gt;INSERT&lt;/code&gt; / &lt;code&gt;UPDATE&lt;/code&gt;. &lt;code&gt;INSERT&lt;/code&gt; policies have &lt;code&gt;WITH CHECK&lt;/code&gt; only. Getting these backwards is how people write an "owners only" update policy whose &lt;code&gt;USING&lt;/code&gt; guards the old row but whose missing &lt;code&gt;WITH CHECK&lt;/code&gt; lets anyone reassign a row to themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;USING (true)&lt;/code&gt; means world-readable for SELECT.&lt;/strong&gt; A &lt;code&gt;SELECT&lt;/code&gt; policy with &lt;code&gt;USING (true)&lt;/code&gt; simply lets everyone read — genuinely fine for a public catalog. It only becomes a &lt;em&gt;write&lt;/em&gt; hole if a permissive &lt;code&gt;INSERT&lt;/code&gt; / &lt;code&gt;UPDATE&lt;/code&gt; / &lt;code&gt;ALL&lt;/code&gt; policy also opens that path. Permissive policies OR together, so one stray &lt;code&gt;using(true)&lt;/code&gt; with no &lt;code&gt;TO&lt;/code&gt; widens access to &lt;code&gt;anon&lt;/code&gt; in whatever command it targets. And a role needs &lt;strong&gt;both&lt;/strong&gt; a matching policy &lt;strong&gt;and&lt;/strong&gt; the table &lt;code&gt;GRANT&lt;/code&gt;: RLS with the grant missing fails closed, while a grant on a table with RLS still disabled fails wide open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — audit the whole schema at once
&lt;/h2&gt;

&lt;p&gt;Before you write per-table tests, get a bird's-eye view. Two read-only queries surface most real problems.&lt;/p&gt;

&lt;p&gt;Tables where RLS is off entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every policy, with the fields that matter — &lt;code&gt;roles&lt;/code&gt;, &lt;code&gt;cmd&lt;/code&gt;, and the actual expressions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- {public} = applies to anon too&lt;/span&gt;
       &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- SELECT / INSERT / UPDATE / DELETE / ALL&lt;/span&gt;
       &lt;span class="n"&gt;qual&lt;/span&gt;       &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;using_expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;check_expr&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&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 like a reviewer: any row where &lt;code&gt;roles&lt;/code&gt; is &lt;code&gt;{public}&lt;/code&gt; and &lt;code&gt;using_expr&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; on a table with private data is a red flag. Any table in the first query's output that isn't meant to be fully public is a bigger one. The demo repo ships this audit SQL as a single file plus a seeded leak you can watch the harness catch — a fast way to confirm your setup works before you point it at your own schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into CI
&lt;/h2&gt;

&lt;p&gt;Because the whole thing is SQL against a throwaway database, it runs anywhere Postgres does. A &lt;code&gt;pg_prove&lt;/code&gt; file or even a plain script that fails when &lt;code&gt;anon_sees &amp;gt; 0&lt;/code&gt; will do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;/rls_anon_readonly.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against &lt;code&gt;supabase db reset&lt;/code&gt; output on every push. Now a policy regression breaks the build instead of leaking rows in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;You now have a repeatable way to prove — not hope — that &lt;code&gt;anon&lt;/code&gt; sees exactly what you intend. Clone &lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;, run the audit against your schema, and fix whatever the &lt;code&gt;pg_policies&lt;/code&gt; query lights up.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-roll the assertions, I keep a small &lt;strong&gt;RLS Audit Kit ($29)&lt;/strong&gt; — the same harness plus a checklist of per-command policy tests (insert-as-other-user, update-reassignment, delete-scope) and copy-paste &lt;code&gt;pg_prove&lt;/code&gt; cases. Entirely optional; the repo above is enough to secure your project today. Either way, go run the anon check before you ship.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Supabase RLS with auth.uid() not working? Causes and fixes</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:20:56 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-rls-with-authuid-not-working-causes-and-fixes-20pj</link>
      <guid>https://dev.to/cekuu35/supabase-rls-with-authuid-not-working-causes-and-fixes-20pj</guid>
      <description>&lt;p&gt;You wrote a tidy Row Level Security policy, something like &lt;code&gt;auth.uid() = user_id&lt;/code&gt;, and now one of two annoying things happens: your query returns &lt;strong&gt;no rows at all&lt;/strong&gt;, or it returns &lt;strong&gt;everybody's rows&lt;/strong&gt;. Both are common, both are fixable, and neither means RLS is broken. It means the policy isn't seeing the identity you think it's seeing.&lt;/p&gt;

&lt;p&gt;Let's walk the actual causes, in rough order of how often they bite people, with copy-pasteable SQL you can run against your own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what &lt;code&gt;auth.uid()&lt;/code&gt; actually is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; reads the &lt;code&gt;sub&lt;/code&gt; claim out of the JWT attached to the current request. No JWT, or a JWT without a user, means &lt;code&gt;auth.uid()&lt;/code&gt; returns &lt;strong&gt;NULL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That single fact explains most "no rows" bugs. A policy like &lt;code&gt;auth.uid() = user_id&lt;/code&gt; becomes &lt;code&gt;NULL = user_id&lt;/code&gt;, which evaluates to NULL (not true), so the row is filtered out. Every row. Silently.&lt;/p&gt;

&lt;p&gt;So the first debugging question is never "is my policy wrong?" It's "&lt;strong&gt;who does the database think I am right now?&lt;/strong&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: You're on the anon key, so auth.uid() is NULL
&lt;/h2&gt;

&lt;p&gt;Every Supabase project ships two keys, and they are not interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;anon / publishable&lt;/strong&gt; (&lt;code&gt;role: anon&lt;/code&gt;) — &lt;strong&gt;public by design&lt;/strong&gt;. It's meant to sit in your browser bundle. It is &lt;em&gt;not&lt;/em&gt; a secret and does &lt;em&gt;not&lt;/em&gt; need rotating. RLS is what protects your data when this key is used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;service_role&lt;/strong&gt; (&lt;code&gt;role: service_role&lt;/code&gt;) — a &lt;strong&gt;server-only secret&lt;/strong&gt; that &lt;strong&gt;bypasses RLS entirely&lt;/strong&gt;. More on why that's dangerous below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you initialize the client with the anon key and the user &lt;strong&gt;hasn't logged in&lt;/strong&gt;, there's no user JWT, so &lt;code&gt;auth.uid()&lt;/code&gt; is NULL and &lt;code&gt;auth.uid() = user_id&lt;/code&gt; matches nothing.&lt;/p&gt;

&lt;p&gt;Confirm which key you're holding by decoding the middle segment of the JWT (base64url-encoded JSON):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PASTE_KEY_HERE'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;role&lt;/code&gt; field: &lt;code&gt;"anon"&lt;/code&gt; or &lt;code&gt;"service_role"&lt;/code&gt;. The newer key formats make it obvious without decoding: &lt;code&gt;sb_publishable_...&lt;/code&gt; is public, &lt;code&gt;sb_secret_...&lt;/code&gt; is the secret one.&lt;/p&gt;

&lt;p&gt;Then confirm you're actually authenticated. In the browser:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// null here means auth.uid() will be NULL server-side&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;user&lt;/code&gt; is null, fix sign-in first — no policy change helps until the request carries a user JWT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: RLS isn't actually enabled, so you get everything
&lt;/h2&gt;

&lt;p&gt;The opposite symptom — you see &lt;strong&gt;all&lt;/strong&gt; rows — usually means RLS isn't switched on for the table. Policies are ignored entirely until you enable RLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&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;relrowsecurity&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, turn it 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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;your_table&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;One gotcha: the table owner and superusers still bypass RLS. To apply the rules even to the owner, add &lt;code&gt;alter table your_table force row level security;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: You're testing with service_role, so it "works" (misleadingly)
&lt;/h2&gt;

&lt;p&gt;The sneakiest one. Your test passes, data comes back exactly right — because the test client is using the &lt;strong&gt;service_role&lt;/strong&gt; key, which &lt;strong&gt;bypasses RLS completely&lt;/strong&gt;. You're not testing your policies; you're testing with the guard switched off.&lt;/p&gt;

&lt;p&gt;service_role belongs &lt;strong&gt;only&lt;/strong&gt; on a trusted server. Never in a browser, never in a public repo, never in a client &lt;code&gt;.env&lt;/code&gt; that gets bundled. Anything prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, &lt;code&gt;VITE_&lt;/code&gt;, or &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; gets &lt;strong&gt;inlined into the client bundle&lt;/strong&gt; at build time — perfect for the anon key and project URL, catastrophic for service_role.&lt;/p&gt;

&lt;p&gt;And if service_role has already been committed to a repo: deleting the file is &lt;strong&gt;not&lt;/strong&gt; enough. A committed secret lives in git history forever. Rotate the key in the dashboard &lt;strong&gt;and&lt;/strong&gt; purge history (&lt;code&gt;git filter-repo&lt;/code&gt; or BFG). Rotation is the part that actually revokes the leak; history-scrubbing stops the old value from being fetched back out.&lt;/p&gt;

&lt;p&gt;Test policies with the &lt;strong&gt;anon key plus a real logged-in session&lt;/strong&gt;, the way your app runs. To check directly in SQL, impersonate a role and a user inside a transaction:&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- become the anon role&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;your_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- honors your anon policies&lt;/span&gt;

&lt;span class="c1"&gt;-- now simulate a logged-in user&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&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;select&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;your_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- auth.uid() now returns that sub&lt;/span&gt;
&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it returns rows here but not in your app, the difference is your app's auth state, not the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: Missing GRANT
&lt;/h2&gt;

&lt;p&gt;RLS policies do not grant access — they &lt;strong&gt;restrict&lt;/strong&gt; it. A role needs &lt;strong&gt;both&lt;/strong&gt; a matching policy &lt;strong&gt;and&lt;/strong&gt; a table-level privilege. If &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; was never granted &lt;code&gt;SELECT&lt;/code&gt;, no policy will save you; the query errors out regardless of the rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role_table_grants&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase's default setup grants the API roles broad privileges, but if you've been locking things down by hand you may have revoked them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="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="n"&gt;your_table&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;Then let RLS decide &lt;em&gt;which rows&lt;/em&gt; each user actually touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 5: Missing (or wrong) TO clause
&lt;/h2&gt;

&lt;p&gt;A policy with &lt;strong&gt;no &lt;code&gt;TO&lt;/code&gt; clause applies to every role&lt;/strong&gt;, including &lt;code&gt;anon&lt;/code&gt;. That's a frequent source of "why can logged-out visitors read this?"&lt;/p&gt;

&lt;p&gt;Scope policies explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users read own rows"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;your_table&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;-- &amp;lt;- this line matters&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="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;Multiple &lt;strong&gt;permissive&lt;/strong&gt; policies are OR'd together, so a broad one you forgot about widens access. List what's really on the 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;select&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;roles&lt;/code&gt; column. If you see &lt;code&gt;{public}&lt;/code&gt; where you expected &lt;code&gt;{authenticated}&lt;/code&gt;, that's your leak — &lt;code&gt;public&lt;/code&gt; means every role, anon included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 6: Comparing a uuid to a text column
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; returns a &lt;strong&gt;uuid&lt;/strong&gt;. If your &lt;code&gt;user_id&lt;/code&gt; column is &lt;code&gt;text&lt;/code&gt;, Postgres resolves &lt;code&gt;uuid = text&lt;/code&gt; by casting the text to uuid — which throws &lt;code&gt;invalid input syntax for type uuid&lt;/code&gt; the moment any value isn't a well-formed uuid, and adds a per-row cast even when it isn't. Check the type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&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;The clean fix is a real &lt;code&gt;uuid&lt;/code&gt; column referencing &lt;code&gt;auth.users&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;your_table&lt;/span&gt;
  &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;user_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you can't change the schema right now, cast inside the policy so both sides match:&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="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="nb"&gt;text&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;Prefer fixing the column type — a &lt;code&gt;uuid&lt;/code&gt; that references &lt;code&gt;auth.users(id)&lt;/code&gt; indexes better and is harder to get wrong later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy pattern to standardize on
&lt;/h2&gt;

&lt;p&gt;Notice I keep writing &lt;code&gt;(select auth.uid())&lt;/code&gt; instead of bare &lt;code&gt;auth.uid()&lt;/code&gt;. Wrapping it in a &lt;code&gt;select&lt;/code&gt; lets Postgres evaluate the function &lt;strong&gt;once per query&lt;/strong&gt; instead of once per row — a real, measurable win on larger tables, and the pattern Supabase now recommends. A complete per-user set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;documents&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 documents"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own documents"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"update own documents"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mental model for the two clauses: &lt;strong&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/strong&gt; filters which existing rows a role can see (and which rows an &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; may touch); &lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/strong&gt; validates the new values being written. &lt;code&gt;INSERT&lt;/code&gt; policies have &lt;code&gt;WITH CHECK&lt;/code&gt; only. &lt;code&gt;UPDATE&lt;/code&gt; usually wants both — &lt;code&gt;USING&lt;/code&gt; to pick the row, &lt;code&gt;WITH CHECK&lt;/code&gt; so a user can't reassign &lt;code&gt;user_id&lt;/code&gt; to someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 60-second self-audit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- tables in public with RLS still disabled&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- policies with no role restriction (they apply to anon too)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{public}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want a reproducible sandbox where each failure mode fires on demand — anon-key NULL, missing GRANT, service_role bypass — plus a fuller read-only audit query to paste into the SQL editor, I put a small demo repo together: &lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;. Clone it, run the SQL, and watch the "no rows / all rows" behavior flip as you toggle each cause. It's the fastest way I know to build the intuition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;auth.uid()&lt;/code&gt; "doesn't work," it's almost never that RLS is broken. It's that the request isn't carrying the identity you assume: you're on the anon key with no session, RLS was never enabled, a test used service_role and hid the bug, a GRANT is missing, the &lt;code&gt;TO&lt;/code&gt; clause is too wide, or a uuid is being compared to text. Walk the six checks in order and you'll find it.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-audit every time you ship a new table, I maintain a &lt;strong&gt;$29 RLS Audit Kit&lt;/strong&gt; — ready-to-run policy tests and a checklist that flags exactly these six failure modes across your whole schema. Entirely optional; the queries above will get you unstuck today either way.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is my Supabase anon key exposed? What's actually safe (and what isn't)</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:19:26 +0000</pubDate>
      <link>https://dev.to/cekuu35/is-my-supabase-anon-key-exposed-whats-actually-safe-and-what-isnt-3ka6</link>
      <guid>https://dev.to/cekuu35/is-my-supabase-anon-key-exposed-whats-actually-safe-and-what-isnt-3ka6</guid>
      <description>&lt;p&gt;You pushed your app, opened DevTools, and there it is in plain sight: your Supabase key, sitting in the JavaScript bundle for the whole world to read. Your stomach drops. Did you just leak the keys to your database?&lt;/p&gt;

&lt;p&gt;Take a breath. In the overwhelming majority of cases, the answer is: &lt;strong&gt;no, that key is public by design and you did nothing wrong.&lt;/strong&gt; But there's a real version of this panic that &lt;em&gt;is&lt;/em&gt; an emergency, and the difference between the two is worth understanding precisely. Let's sort it out calmly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Supabase project has two keys
&lt;/h2&gt;

&lt;p&gt;When you spin up a project, Supabase hands you two API keys, and they could not be more different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;anon / publishable key&lt;/strong&gt; (&lt;code&gt;role: anon&lt;/code&gt;) — the public one. It is &lt;em&gt;meant&lt;/em&gt; to ship in your browser bundle, your mobile app, your public repo. It is not a secret. It does not need rotating just because someone saw it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;service_role key&lt;/strong&gt; (&lt;code&gt;role: service_role&lt;/code&gt;) — a server-only secret. It &lt;strong&gt;bypasses Row Level Security entirely&lt;/strong&gt; — full read, write, and delete on every table, plus Storage. This one must never touch a browser, a public repo, or client config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Newer projects use the &lt;code&gt;sb_publishable_...&lt;/code&gt; and &lt;code&gt;sb_secret_...&lt;/code&gt; formats, where the prefix alone tells you which is which. Older projects use JWTs that both start with &lt;code&gt;eyJ...&lt;/code&gt; and look nearly identical at a glance — which is exactly why people panic.&lt;/p&gt;

&lt;p&gt;So the first question isn't "is my key exposed?" It's &lt;strong&gt;"which key is exposed?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decode the key to find out which one you have
&lt;/h2&gt;

&lt;p&gt;Both legacy keys are JWTs: three base64url segments separated by dots. The middle segment is the payload, and it names the role. You never need a library — just decode the middle part.&lt;/p&gt;

&lt;p&gt;In the browser console:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eyJhbGciOi...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// paste your key&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="c1"&gt;// { "role": "anon", "iss": "supabase", ... }  -&amp;gt; public, safe to ship&lt;/span&gt;
&lt;span class="c1"&gt;// { "role": "service_role", ... }             -&amp;gt; secret, get it out now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or from a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"eyJhbGciOi..."&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;"role":"anon"&lt;/code&gt; — relax. That key in your bundle is doing exactly what it's supposed to.&lt;/p&gt;

&lt;p&gt;If you see &lt;code&gt;"role":"service_role"&lt;/code&gt; — this is the real emergency. Skip to the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the anon key safe to make public?
&lt;/h2&gt;

&lt;p&gt;Because the anon key isn't what protects your data. &lt;strong&gt;Row Level Security (RLS) is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Think of the anon key as a library card that only says "this person is an anonymous visitor." It gets you through the front door of the API. What you can actually &lt;em&gt;read or touch&lt;/em&gt; once inside is decided by RLS policies on each table. With RLS enabled and no policy granting access, the anon role sees nothing.&lt;/p&gt;

&lt;p&gt;This is the mental-model shift that dissolves the panic: &lt;strong&gt;exposing the anon key is only a problem if your tables aren't protected by RLS.&lt;/strong&gt; The key was never the wall. It's the doorbell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual thing to check: is RLS on and scoped?
&lt;/h2&gt;

&lt;p&gt;Here's where the genuine risk lives, and it has nothing to do with the key being visible. Run this in the Supabase SQL editor to find every table in &lt;code&gt;public&lt;/code&gt; with RLS switched off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;relnamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any row where &lt;code&gt;rls_enabled&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; is readable and writable by anyone holding your anon key — which, again, is everyone. That's the leak that matters.&lt;/p&gt;

&lt;p&gt;Enabling RLS is one line per 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;alter&lt;/span&gt; &lt;span class="k"&gt;table&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;profiles&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;But RLS with &lt;strong&gt;no policies&lt;/strong&gt; means nobody except service_role can read anything — so you then add policies that scope access. A quick model of how policies work, because the details trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/strong&gt; filters which existing rows a role may see, and which it may &lt;code&gt;UPDATE&lt;/code&gt;/&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 being inserted or updated. &lt;code&gt;INSERT&lt;/code&gt; policies have &lt;code&gt;WITH CHECK&lt;/code&gt; only.&lt;/li&gt;
&lt;li&gt;A role needs &lt;strong&gt;both&lt;/strong&gt; a matching policy &lt;strong&gt;and&lt;/strong&gt; the table &lt;code&gt;GRANT&lt;/code&gt;. Permissive policies OR together; restrictive ones AND.&lt;/li&gt;
&lt;li&gt;A policy with &lt;strong&gt;no &lt;code&gt;TO&lt;/code&gt; clause applies to every role, including &lt;code&gt;anon&lt;/code&gt;.&lt;/strong&gt; Classic footgun.&lt;/li&gt;
&lt;li&gt;For the anon role, &lt;code&gt;auth.uid()&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; — so &lt;code&gt;auth.uid() = user_id&lt;/code&gt; naturally matches nothing for anonymous callers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical "users only see their own rows" 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="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own rows are visible"&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;profiles&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;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;Now audit what you actually have. This lists every policy and its expressions so you can eyeball the dangerous ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;using_expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two patterns to hunt for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;using (true)&lt;/code&gt; on a &lt;code&gt;SELECT&lt;/code&gt; policy that reaches anon&lt;/strong&gt; = world-readable table. Sometimes intentional (a public blog); often not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;using (true)&lt;/code&gt; on an &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;ALL&lt;/code&gt; policy&lt;/strong&gt; = world-&lt;em&gt;writable&lt;/em&gt;. That's how strangers end up inserting rows into your database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If reading raw &lt;code&gt;pg_policies&lt;/code&gt; output makes your eyes cross, I put together a small public repo that reproduces the exact leaky-vs-safe setup on a throwaway project, with the read-only audit SQL ready to paste: &lt;strong&gt;&lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/a&gt;&lt;/strong&gt;. Clone it, run the queries against your own project, and you'll see immediately which tables are exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NEXT_PUBLIC_ question
&lt;/h2&gt;

&lt;p&gt;Frameworks inline any env var prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; (Next.js), &lt;code&gt;VITE_&lt;/code&gt; (Vite), or &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; (Expo) directly into the client bundle. That's &lt;em&gt;correct&lt;/em&gt; for the anon key and project URL — they're meant to be public:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://xxxx.supabase.co
&lt;span class="nv"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eyJhbGci...   &lt;span class="c"&gt;# fine, this is public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catastrophe is putting the service_role key behind a public prefix. &lt;code&gt;NEXT_PUBLIC_SERVICE_ROLE_KEY&lt;/code&gt; ships an RLS-bypassing master key to every visitor. Service_role belongs only in server code — API routes, Edge Functions, backend jobs — under an &lt;em&gt;unprefixed&lt;/em&gt; name.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you exposed the service_role key
&lt;/h2&gt;

&lt;p&gt;This is the one case that's a genuine incident. Do this now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate it.&lt;/strong&gt; Supabase Dashboard -&amp;gt; Project Settings -&amp;gt; API Keys -&amp;gt; roll the service_role key. Every copy of the old one stops working immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge it from git history.&lt;/strong&gt; Deleting the file isn't enough — a committed secret lives in history forever. Use &lt;code&gt;git filter-repo&lt;/code&gt; or BFG:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   git filter-repo &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; .env
   &lt;span class="c"&gt;# or: bfg --delete-files .env &amp;amp;&amp;amp; git reflog expire --expire=now --all &amp;amp;&amp;amp; git gc --prune=now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Force-push, then rotate anything else that shared that file.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Anon key in your bundle? &lt;strong&gt;Expected. Safe. Don't rotate it.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The real question is whether &lt;strong&gt;RLS is on and correctly scoped&lt;/strong&gt; on every table.&lt;/li&gt;
&lt;li&gt;Service_role key anywhere public? &lt;strong&gt;Rotate and purge history immediately.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Decode your key, run the two audit queries above, and you'll know exactly where you stand in about five minutes.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-audit every policy — especially on a project with dozens of tables where one stray &lt;code&gt;using (true)&lt;/code&gt; is easy to miss — I've packaged the full policy checklist, annotated audit queries, and a scoring rubric into a &lt;strong&gt;$29 RLS Audit Kit&lt;/strong&gt;. Totally optional; the free demo repo above gets most people what they need. Either way, don't let a screenshot of your anon key ruin your afternoon. Check RLS instead.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your 3D Model Won't Open on iPhone — and the No-Install GLB to USDZ Fix</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:01:50 +0000</pubDate>
      <link>https://dev.to/cekuu35/why-your-3d-model-wont-open-on-iphone-and-the-no-install-glb-to-usdz-fix-8p2</link>
      <guid>https://dev.to/cekuu35/why-your-3d-model-wont-open-on-iphone-and-the-no-install-glb-to-usdz-fix-8p2</guid>
      <description>&lt;h1&gt;
  
  
  Why Your 3D Model Won't Open on iPhone — and the No-Install GLB → USDZ Fix
&lt;/h1&gt;

&lt;p&gt;If you have ever sent a &lt;code&gt;.glb&lt;/code&gt; file to someone with an iPhone and watched Safari shrug, you already know the wall: &lt;strong&gt;Apple's AR ecosystem does not speak glTF.&lt;/strong&gt; iOS AR Quick Look, Safari previews, and Vision Pro all want one format: &lt;strong&gt;USDZ&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the story of that wall, why the usual tools don't help half of us, and how I ended up shipping a browser-only converter at &lt;a href="https://www.renderivo.com/convert" rel="noopener noreferrer"&gt;renderivo.com/convert&lt;/a&gt; (first conversion free, no account) — with the technical details of what actually happens inside the conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall, precisely
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;glTF/GLB&lt;/strong&gt; is the web standard. Three.js, &lt;code&gt;&amp;lt;model-viewer&amp;gt;&lt;/code&gt;, Shopify 3D, WebXR — they all eat GLB happily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USDZ&lt;/strong&gt; is Apple's AR format — a zip archive containing USDA (text scene description) plus textures. AR Quick Look on iOS literally refuses anything else for AR previews.&lt;/li&gt;
&lt;li&gt;So any pipeline that touches an iPhone — "scan this QR code to see the product in your living room" — needs a USDZ sitting at a URL. On Android the same link works with GLB (Scene Viewer). On iPhone: USDZ or nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the usual answers don't help
&lt;/h2&gt;

&lt;p&gt;The internet's standard advice for GLB → USDZ is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Apple's Reality Converter&lt;/strong&gt; — macOS only. If you are on Windows or Linux, this tool does not exist for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blender&lt;/strong&gt; — can import glTF, and with the right USD plugin can export USD. Expect an afternoon of plugin archaeology, and check your materials afterwards; the glTF PBR → USD conversion path is exactly where things silently break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud converters&lt;/strong&gt; — upload your model to somebody else's server. Fine for a sketchfab freebie; a different conversation when the model is a client deliverable under NDA.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have a Mac, open Reality Converter, done, happy for you. The rest of this post is for everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a conversion actually has to do
&lt;/h2&gt;

&lt;p&gt;I shipped a converter that runs the whole pipeline in the browser (the &lt;a href="https://dev.to/cekuu35/glb-to-usdz-in-the-browser-how-it-works-and-why-your-files-never-leave-your-machine-33d"&gt;first post&lt;/a&gt; covers the internals). The short version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the GLB&lt;/strong&gt; with &lt;code&gt;@gltf-transform&lt;/code&gt; (WebAssembly, in-browser — the file never leaves the machine).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean the geometry&lt;/strong&gt;: &lt;code&gt;weld()&lt;/code&gt; merges duplicate vertices, &lt;code&gt;dedup()&lt;/code&gt; removes duplicated accessors, &lt;code&gt;prune()&lt;/code&gt; strips unused nodes and extensions, &lt;code&gt;resample()&lt;/code&gt; compresses animation keyframes. This is where a surprising amount of dead weight lives in marketplace assets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the textures.&lt;/strong&gt; Two things bit me here and are worth knowing even if you never touch my tool:

&lt;ul&gt;
&lt;li&gt;Never re-encode a texture into a different container than its declared mime type (WebP textures declared as WebP must stay WebP bytes, or viewers start guessing).&lt;/li&gt;
&lt;li&gt;Some Chromium builds default canvas WebP encoding to &lt;em&gt;lossless&lt;/em&gt; — a "downsized" texture can come out 3× bigger than the original. Always pass an explicit &lt;code&gt;quality&lt;/code&gt;, and keep the original bytes if the re-encode came out larger.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit USDZ&lt;/strong&gt; with three.js &lt;code&gt;USDZExporter&lt;/code&gt; — it builds the USDA text scene, rasterizes textures, and zips everything into a valid &lt;code&gt;.usdz&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: one file in, two files out — the AR-ready USDZ, plus the cleaned-up GLB for the web side — with before/after sizes shown so you know what actually happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you actually need this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom storefronts and headless commerce&lt;/strong&gt; wiring up &lt;code&gt;&amp;lt;model-viewer&amp;gt;&lt;/code&gt; yourself: the GLB goes in &lt;code&gt;src&lt;/code&gt;, the USDZ goes in &lt;code&gt;ios-src&lt;/code&gt;, and Android/iOS AR both work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product demos via link or QR&lt;/strong&gt; — USDZ hosted anywhere opens straight into AR Quick Look on any modern iPhone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision Pro&lt;/strong&gt; content previews — same USDZ surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client handoffs&lt;/strong&gt; — "we need the web version and the Apple version" stops meaning installing a DCC tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;Draco-compressed inputs are not supported in the current version (re-export without Draco). Morph targets don't survive the USDZ hop yet. And a 150 MB monster GLB will still make your browser fan spin — the ceiling is your RAM, not a queue.&lt;/p&gt;




&lt;p&gt;The converter is at &lt;a href="https://www.renderivo.com/convert" rel="noopener noreferrer"&gt;renderivo.com/convert&lt;/a&gt; — drop a file, watch it convert, download both formats. The first conversion is free and no account touches it; a one-time license unlocks unlimited conversions with no subscription.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ios</category>
      <category>3d</category>
      <category>ar</category>
    </item>
    <item>
      <title>MCP configs are the new .env files — I found live database keys committed on GitHub</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Tue, 20 Oct 2026 23:58:18 +0000</pubDate>
      <link>https://dev.to/cekuu35/mcp-configs-are-the-new-env-files-i-found-live-database-keys-committed-on-github-457p</link>
      <guid>https://dev.to/cekuu35/mcp-configs-are-the-new-env-files-i-found-live-database-keys-committed-on-github-457p</guid>
      <description>&lt;p&gt;MCP configs are the new .env files. I scanned GitHub and found live database keys committed in public repos.&lt;/p&gt;

&lt;p&gt;If you use Claude Desktop, Claude Code, Cursor or Windsurf with external tools, you have an &lt;code&gt;.mcp.json&lt;/code&gt; or &lt;code&gt;claude_desktop_config.json&lt;/code&gt; file on your machine. It tells your editor which MCP servers to start, and each server entry can carry an &lt;code&gt;env&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;env&lt;/code&gt; block is a secret store. And developers are pasting real keys into it — then committing the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found
&lt;/h2&gt;

&lt;p&gt;I ran GitHub code search for &lt;code&gt;filename:.mcp.json SUPABASE_SERVICE_ROLE&lt;/code&gt; and similar queries, then verified every result instead of trusting the match:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decode, don't pattern-match.&lt;/strong&gt; A file only counts if it contains a JWT whose decoded payload literally says &lt;code&gt;"role": "service_role"&lt;/code&gt;. In my scan, 53 of 65 candidate files were placeholders, templates or examples. If I had emailed all 65, four out of five would have been noise — the fastest way to teach someone to ignore security reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe whether the project is live.&lt;/strong&gt; A leaked key for a deleted Supabase project is worthless. A &lt;code&gt;GET https://&amp;lt;ref&amp;gt;.supabase.co/rest/v1/&lt;/code&gt; that answers &lt;code&gt;401&lt;/code&gt; (no credentials sent, nothing read) proves the project is live and the key is valid until rotated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: &lt;strong&gt;12 files in public repos contain a real, decodable &lt;code&gt;service_role&lt;/code&gt; key. 4 of those projects are live right now.&lt;/strong&gt; One of them was pushed the same day I scanned.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;service_role&lt;/code&gt; key bypasses every row-level-security policy. Whoever finds it has full read/write access to the database — not "some" access, all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;The mental model is wrong, not the people. &lt;code&gt;.mcp.json&lt;/code&gt; feels like &lt;em&gt;editor configuration&lt;/em&gt; — like &lt;code&gt;settings.json&lt;/code&gt;, like &lt;code&gt;.vscode/&lt;/code&gt;. Nobody expects a linter or a framework to warn them, because no tool in this ecosystem treats the file as a secret store yet.&lt;/p&gt;

&lt;p&gt;Compare with &lt;code&gt;.env&lt;/code&gt;: a decade of leaked keys produced pre-commit hooks, secret scanners, CI checks, and platform warnings. &lt;code&gt;.mcp.json&lt;/code&gt; has none of that guardrail history, so the same 2015-style mistakes are being made again in 2026 — just with database keys that grant more access than most API keys ever did.&lt;/p&gt;

&lt;p&gt;Deleting the file doesn't help either: git history keeps every version forever. The only fix that works is &lt;strong&gt;rotating the key&lt;/strong&gt; (Supabase Dashboard → Settings → API → rotate), then moving the new value into a runtime environment variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built the linter I wanted to exist
&lt;/h2&gt;

&lt;p&gt;I've spent months doing Supabase RLS audits. The configuration side of this problem needs zero-expertise tooling, so I built a family of zero-dependency auditors. Each one runs locally, reads nothing from the network, and redacts evidence in its reports so the report itself is safe to paste into an issue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/cekuu35/mcp-config-security" rel="noopener noreferrer"&gt;mcp-config-security&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npx mcp-config-security&lt;/code&gt;. Scans Claude Desktop, Claude Code, Cursor and Windsurf configs for service_role JWTs (decoded, so a token hiding under a neutral key name is still caught), real-looking tokens in &lt;code&gt;env&lt;/code&gt;/&lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; flags, silent &lt;code&gt;autoApprove&lt;/code&gt; on write tools, plaintext &lt;code&gt;http://&lt;/code&gt; remotes, disabled TLS verification, and filesystem servers rooted at &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/cekuu35/supabase-service-role-scanner" rel="noopener noreferrer"&gt;supabase-service-role-scanner&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npx supabase-service-role-scanner&lt;/code&gt;. Walks a project for &lt;code&gt;NEXT_PUBLIC_*SERVICE_ROLE*&lt;/code&gt; variables, &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values carrying non-anon JWTs, and service_role literals in source. The &lt;code&gt;anon&lt;/code&gt; key in &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is correct by design and never flagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/cekuu35/github-actions-audit" rel="noopener noreferrer"&gt;github-actions-audit&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npx github-actions-audit&lt;/code&gt;. Static scan of workflow YAML for &lt;code&gt;pull_request_target&lt;/code&gt; pwn requests, &lt;code&gt;${{ github.event.pull_request.title }}&lt;/code&gt;-style shell injection, hardcoded secrets, and unpinned actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/cekuu35/firebase-rules-audit" rel="noopener noreferrer"&gt;firebase-rules-audit&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npx firebase-rules-audit&lt;/code&gt;. Finds &lt;code&gt;allow read, write: if true;&lt;/code&gt;, &lt;code&gt;request.auth == null&lt;/code&gt; grants and conditions with no auth check at all, in Firestore and Storage rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/cekuu35/n8n-workflow-audit" rel="noopener noreferrer"&gt;n8n-workflow-audit&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npx n8n-workflow-audit&lt;/code&gt;. Exported workflow JSON often carries tokens pasted straight into HTTP Request nodes. This flags hardcoded credentials, &lt;code&gt;?apikey=&lt;/code&gt; in URLs and unauthenticated webhooks — while treating n8n's own &lt;code&gt;credentials: { id, name }&lt;/code&gt; references as the correct pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All free, all MIT, no accounts, no telemetry. The companion repo &lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;supabase-rls-leak-demo&lt;/a&gt; covers the database side: a fixture that goes red on a broken RLS policy and green on the one-file fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own tool flagged my own CIs
&lt;/h2&gt;

&lt;p&gt;The first thing I ran against my own four repos was &lt;code&gt;github-actions-audit&lt;/code&gt;. It came back with &lt;strong&gt;20 findings&lt;/strong&gt;: every &lt;code&gt;actions/checkout@v4&lt;/code&gt; and &lt;code&gt;actions/setup-node@v4&lt;/code&gt; was a mutable tag, and none of the workflows had a &lt;code&gt;permissions:&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;That is the correct experience for this class of tool — it should be slightly embarrassing. I resolved the action tags to their commit SHAs (&lt;code&gt;git.ref/tags/v4&lt;/code&gt;), pinned all four workflows, added &lt;code&gt;permissions: contents: read&lt;/code&gt;, and pushed. Same command now exits &lt;code&gt;0&lt;/code&gt;. If a security linter finds nothing on its first run against your config soup, be suspicious of the linter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do tonight
&lt;/h2&gt;

&lt;p&gt;If you have an &lt;code&gt;.mcp.json&lt;/code&gt; or &lt;code&gt;claude_desktop_config.json&lt;/code&gt; anywhere in a repo — or a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; Supabase variable you're not sure about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx mcp-config-security
npx supabase-service-role-scanner &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two commands, a few seconds, zero installs. If they come back clean, you're done. If they don't, rotate first, refactor second.&lt;/p&gt;

&lt;p&gt;I reported the live leaks I found privately — no bounties asked, just the rotation. If one of these tools catches something real for you, &lt;a href="https://www.buymeacoffee.com/cenkkurtoglu" rel="noopener noreferrer"&gt;you can buy me a coffee&lt;/a&gt;, but the tools stay free either way.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>cybersecurity</category>
      <category>github</category>
      <category>security</category>
    </item>
    <item>
      <title>GLB to USDZ in the Browser: How It Works and Why Your Files Never Leave Your Machine</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Tue, 20 Oct 2026 21:24:03 +0000</pubDate>
      <link>https://dev.to/cekuu35/glb-to-usdz-in-the-browser-how-it-works-and-why-your-files-never-leave-your-machine-33d</link>
      <guid>https://dev.to/cekuu35/glb-to-usdz-in-the-browser-how-it-works-and-why-your-files-never-leave-your-machine-33d</guid>
      <description>&lt;h1&gt;
  
  
  GLB to USDZ in the Browser: How It Works and Why Your Files Never Leave Your Machine
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpublic-files.gumroad.com%2Fyg8jzkfcviwbta061m4myds1cbjf" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpublic-files.gumroad.com%2Fyg8jzkfcviwbta061m4myds1cbjf" alt="GLB in. USDZ out." width="1280" height="769"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have ever tried to put a 3D model on an iPhone, you have hit this wall: iOS AR Quick Look — and Vision Pro, and Apple's AR tooling generally — only opens &lt;strong&gt;USDZ&lt;/strong&gt;. Meanwhile, almost every 3D asset you download or generate arrives as &lt;strong&gt;GLB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The usual options are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fire up Blender, import, export as USDZ, hope the materials survive.&lt;/li&gt;
&lt;li&gt;Use a cloud converter — and upload your (possibly client-owned) model to somebody else's server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I spent last week shipping a third option: a converter that runs entirely in the browser at &lt;a href="https://www.renderivo.com/convert" rel="noopener noreferrer"&gt;renderivo.com/convert&lt;/a&gt;. No uploads, no accounts, works offline once the page loads. This post is the technical walkthrough — and the honest list of things that bit me along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Three pieces do all the work, all client-side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://gltf-transform.dev/" rel="noopener noreferrer"&gt;&lt;code&gt;@gltf-transform&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — parses the GLB and cleans the geometry: &lt;code&gt;weld()&lt;/code&gt;, &lt;code&gt;dedup()&lt;/code&gt;, &lt;code&gt;prune()&lt;/code&gt;, &lt;code&gt;resample()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OffscreenCanvas&lt;/code&gt;&lt;/strong&gt; — optional texture downsizing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;three.js &lt;code&gt;GLTFLoader&lt;/code&gt; + &lt;code&gt;USDZExporter&lt;/code&gt;&lt;/strong&gt; — builds the AR-ready USDZ archive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Condensed, the core of it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebIO&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gltf-transform/core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ALL_EXTENSIONS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gltf-transform/extensions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;weld&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prune&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resample&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gltf-transform/functions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GLTFLoader&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;three/examples/jsm/loaders/GLTFLoader.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;USDZExporter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;three/examples/jsm/exporters/USDZExporter.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebIO&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;registerExtensions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALL_EXTENSIONS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readBinary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Geometry cleanup&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;weld&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;dedup&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;prune&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;resample&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. (optional) texture resize — see gotcha #1&lt;/span&gt;
  &lt;span class="c1"&gt;// 3. Write the optimized GLB&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;optimizedGlb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeBinary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. USDZ via three.js&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GLTFLoader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gltf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;optimizedGlb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usdz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;USDZExporter&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;parseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gltf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxTextureSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;usdz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;optimizedGlb&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WebIO&lt;/code&gt; is the browser build of the I/O service — same API as the Node version, no &lt;code&gt;fs&lt;/code&gt; involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit shipping this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Never re-encode a texture into a different container than its declared mime type.&lt;/strong&gt; GLB textures can be WebP (declared via &lt;code&gt;EXT_texture_webp&lt;/code&gt;). My first texture-resize pass drew the bitmap to a canvas and re-encoded everything to JPEG for size — while leaving the texture's mime type as &lt;code&gt;image/webp&lt;/code&gt;. The output declared WebP but contained JPEG bytes. Everything &lt;em&gt;looked&lt;/em&gt; fine until you decoded it elsewhere. Keep the re-encode in the same container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;texture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMimeType&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&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;2. Some Chromium builds default canvas WebP to lossless.&lt;/strong&gt; I watched a 1024×1024 WebP texture &lt;em&gt;shrink to 512×512 and grow from 146 KB to 426 KB&lt;/em&gt;. Passing an explicit &lt;code&gt;quality&lt;/code&gt; (e.g. &lt;code&gt;0.85&lt;/code&gt;) forces the lossy encoder. Also: keep the "if the re-encode came out bigger, keep the original bytes" guard — it costs three lines and has saved every weird edge case so far.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Geometry cleanup preserves &lt;code&gt;KHR_mesh_quantization&lt;/code&gt; — good, but know it.&lt;/strong&gt; A quantized model has &lt;code&gt;Int16Array&lt;/code&gt; positions instead of &lt;code&gt;float32&lt;/code&gt;. &lt;code&gt;weld()&lt;/code&gt;/&lt;code&gt;dedup()&lt;/code&gt;/&lt;code&gt;prune()&lt;/code&gt; operate fine on quantized data and the output stays quantized, so your 900 KB quantized model does not suddenly become a 2.4 MB float blob. If you were expecting float blowups from cleanup, you will not get them — which is exactly what you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Draco-compressed &lt;em&gt;inputs&lt;/em&gt; are the one thing I left out of v1.&lt;/strong&gt; The &lt;code&gt;draco3dgltf&lt;/code&gt; npm package is an Emscripten build that &lt;code&gt;require("fs")&lt;/code&gt; at the top level, which no browser bundler enjoys. Serving the WASM decoder properly is a v1.1 item; for now such files fail with a clear parse error and a "re-export without Draco" message. Honest limitation beats a broken bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real numbers
&lt;/h2&gt;

&lt;p&gt;From the sample models I test with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output GLB (half-size textures)&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Textured owl (WebP, quantized)&lt;/td&gt;
&lt;td&gt;962 KB&lt;/td&gt;
&lt;td&gt;790 KB&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean geometry crate&lt;/td&gt;
&lt;td&gt;8.5 KB&lt;/td&gt;
&lt;td&gt;8.4 KB&lt;/td&gt;
&lt;td&gt;~2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Already-clean models barely shrink — that is correct behavior, not a failure. The wins come from texture-heavy marketplace assets and duplicated-vertex exports, which routinely shed 40–80%.&lt;/p&gt;

&lt;p&gt;One more property of this architecture worth stating: &lt;strong&gt;the server never touches your files&lt;/strong&gt;. Conversion happens on your device with WebAssembly; the only network call the tool ever makes is the license check against Gumroad's API when you unlock it. For anyone converting client deliverables, that is the actual selling point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.renderivo.com/convert" rel="noopener noreferrer"&gt;renderivo.com/convert&lt;/a&gt; is live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drop a &lt;code&gt;.glb&lt;/code&gt; (up to 150 MB), get an AR-ready &lt;strong&gt;USDZ&lt;/strong&gt; plus the cleaned &lt;strong&gt;GLB&lt;/strong&gt;, with before/after sizes shown every run.&lt;/li&gt;
&lt;li&gt;The first conversion is free, no account, no email.&lt;/li&gt;
&lt;li&gt;Unlimited conversions is a &lt;strong&gt;$19 one-time license&lt;/strong&gt; — you get a key on your Gumroad receipt, paste it once, done. No subscription, and the unlock lives in your own browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full pipeline (gltf-transform + three.js + the texture-resize guard) is what ships to production — if you run into one of the gotchas above with your own models, I would genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>3d</category>
      <category>threejs</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
