<?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>I Found 60+ Live Supabase Keys in Public Repos, So I Benchmark Whether LLMs Can Spot Them</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 30 Sep 2026 18:23:17 +0000</pubDate>
      <link>https://dev.to/cekuu35/i-found-60-live-supabase-keys-in-public-repos-so-i-benchmark-whether-llms-can-spot-them-3o3m</link>
      <guid>https://dev.to/cekuu35/i-found-60-live-supabase-keys-in-public-repos-so-i-benchmark-whether-llms-can-spot-them-3o3m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;As part of my day-to-day security work, I scan public GitHub repos for committed Supabase credentials. In three days of scanning I found &lt;strong&gt;60+ live service_role keys&lt;/strong&gt; — keys that bypass Row Level Security entirely and give full read/write access to real production databases. Real finds include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;config.php&lt;/code&gt; files with hardcoded service_role keys (a CRM, an LMS, a driving school's backend)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.example&lt;/code&gt; templates where someone pasted a &lt;em&gt;real&lt;/em&gt; key instead of a placeholder&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;-prefixed service_role keys — which means the key ships &lt;strong&gt;in the browser bundle&lt;/strong&gt; to every visitor&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;docker-compose.yml&lt;/code&gt; committing three secrets at once: DB password, anon key, and service_role key&lt;/li&gt;
&lt;li&gt;Deploy docs (&lt;code&gt;DEPLOY.md&lt;/code&gt;, &lt;code&gt;RAILWAY_ENV_SETUP.md&lt;/code&gt;) with full credentials "for convenience"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That got me wondering: &lt;strong&gt;the people leaking these keys are often using AI assistants to write this code. Can the models themselves spot what they helped commit?&lt;/strong&gt; So I built a benchmark on Kaggle Benchmarks that treats models like a security reviewer: given a realistic file snippet, triage it — is there a service_role key (critical), an anon key (medium), a DB password, or nothing (placeholder/docs-only)?&lt;/p&gt;

&lt;p&gt;The 10 cases are all modeled on real leak patterns I actually found (every key in the benchmark is a fake, structurally-valid JWT):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hardcoded service_role + anon in PHP config&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; with a single anon key&lt;/li&gt;
&lt;li&gt;Client-side &lt;code&gt;createClient()&lt;/code&gt; fallback with anon key&lt;/li&gt;
&lt;li&gt;Deploy doc with service_role key + DB password&lt;/li&gt;
&lt;li&gt;Docs with placeholders only (the false-positive trap)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; service_role fallback in a client file — bundles into the browser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.example&lt;/code&gt; left with a real service_role key&lt;/li&gt;
&lt;li&gt;Commented-out "old key kept for reference" — still a live secret&lt;/li&gt;
&lt;li&gt;Two keys side by side (anon + service_role) — models must classify both correctly&lt;/li&gt;
&lt;li&gt;A base64-obfuscated service_role used in a bash script — requires two-step decoding&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each model answers a strict JSON verdict (&lt;code&gt;has_service_role&lt;/code&gt;, &lt;code&gt;has_anon&lt;/code&gt;, &lt;code&gt;has_db_password&lt;/code&gt;, &lt;code&gt;warning_level&lt;/code&gt;, &lt;code&gt;reasoning&lt;/code&gt;) and the task asserts every field. Score = fraction of cases fully correct. Temperature 0. One submission per participant, so I made the cases count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;Nine models from the Kaggle Benchmarks suite, picked to cover the spectrum developers actually choose between — frontier flagships, cheap/fast workhorses, and open-source reasoning models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;anthropic/claude-sonnet-5&lt;/li&gt;
&lt;li&gt;google/gemini-3.7-flash&lt;/li&gt;
&lt;li&gt;google/gemini-3-flash-preview&lt;/li&gt;
&lt;li&gt;google/gemini-3.1-flash-lite-preview&lt;/li&gt;
&lt;li&gt;openai/gpt-5.4-nano&lt;/li&gt;
&lt;li&gt;openai/gpt-oss-120b&lt;/li&gt;
&lt;li&gt;deepseek-ai/deepseek-r1-0528&lt;/li&gt;
&lt;li&gt;qwen/qwen3-next-80b-a3b-instruct&lt;/li&gt;
&lt;li&gt;ibm/granite-4.0-h-small&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your AI assistant is one of these, this is literally a test of "would my assistant catch its own leak?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&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;Score&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean sweep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.7-flash&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean sweep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3-flash-preview&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Decoded the base64 blob mid-response to find the role claim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-flash-lite&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean sweep, cheapest tier to do it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-nano&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;td&gt;Missed the commented-out key + the base64 obfuscation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deepseek-r1-0528&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;td&gt;Flagged &lt;em&gt;everything&lt;/em&gt; critical — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3-next-80b&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;Passed 5/6 assertions before rate-limiting out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Errored repeatedly (provider 429 under load; excluded from comparison)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The headline: modern models are scarily good at the obvious cases
&lt;/h3&gt;

&lt;p&gt;On the five "classic" cases (hardcoded config keys, docs with credentials, placeholder-only false-positive traps), every frontier model scored perfectly. Even gpt-5.4-nano — the cheap, fast one — went 5-for-5. If you paste a file with &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY=eyJ...&lt;/code&gt; into any of these models and ask "is this bad?", they will all tell you correctly: yes, critical, rotate it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The interesting part is where they diverge
&lt;/h3&gt;

&lt;p&gt;DeepSeek-R1, a dedicated reasoning model, scored &lt;strong&gt;0/10&lt;/strong&gt; — but not because it missed the keys. It flagged &lt;em&gt;everything&lt;/em&gt;: every file, including the placeholder-only &lt;code&gt;DEPLOY.md&lt;/code&gt;, came back "critical, service_role present, DB password present." In security triage, a reviewer who cries critical on every file gets ignored on the one that matters. &lt;strong&gt;Recall without precision is just alarm fatigue with extra steps.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The edge cases did their job. gpt-5.4-nano missed exactly the two sneaky ones: the commented-out &lt;em&gt;"old key kept for reference"&lt;/em&gt; (it treated the comment as dead code — but commented secrets still work, and old keys often still rotate back into service) and the base64-obfuscated key. The frontier models — including the cheap Gemini Flash tiers — caught all ten, with Gemini 3 Flash visibly decoding the base64 blob mid-response to identify the role claim before answering.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this means in practice
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your AI assistant will not save you from committed secrets — but it can.&lt;/strong&gt; Every model tested can catch these leaks instantly when asked. The problem is nobody asks. Secrets get committed because the review step never happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-flagging is its own failure mode.&lt;/strong&gt; A reviewer (human or AI) that cries "critical" on every placeholder teaches teams to ignore alarms. Precision matters as much as recall in security triage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 15-minute fix beats any detector.&lt;/strong&gt; Rotate the key in the Supabase dashboard (dies instantly), move it to env vars, &lt;code&gt;git filter-repo&lt;/code&gt; if you want it out of history.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What I'd measure next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool use&lt;/strong&gt;: give the models a repo tree and a search tool, and see if they &lt;em&gt;actively hunt&lt;/em&gt; for secrets rather than judging a pasted file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-file context&lt;/strong&gt;: the real-world version of this leak is spread across &lt;code&gt;config.php&lt;/code&gt; + deploy docs + docker-compose — does performance drop when the secret is two hops away?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix quality&lt;/strong&gt;: flagging is step one; does the model produce a &lt;em&gt;correct&lt;/em&gt; remediation (rotate first, then remove, then history-clean)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run the same checks on your own app
&lt;/h2&gt;

&lt;p&gt;The 10 cases above are one snapshot. Your codebase will have its own version.&lt;/p&gt;

&lt;p&gt;I put nine read-only Postgres queries that cover the same failure classes into a free file - nothing leaves your database, you run it in your own SQL editor:&lt;br&gt;
&lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=kaggle_bench" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo - audit/rls-audit.sql&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same repo has a red/green test suite that demonstrates a cross-tenant leak and the one-file fix.&lt;/p&gt;

&lt;p&gt;If you want the full guided version - write-side checks, a role-simulation harness, and remediation templates you can drop into an existing repo - that's the &lt;a href="https://cengokurtoglu.gumroad.com/l/supabase-rls-audit-kit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=kaggle_bench" rel="noopener noreferrer"&gt;Supabase RLS Audit Kit&lt;/a&gt;. It runs entirely against your own catalogs, $29.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: both links are mine. The audit queries and demo are free and MIT licensed.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;p&gt;Task: &lt;a href="https://www.kaggle.com/benchmarks/tasks/cenkkurtolu/committed-supabase-key-detection" rel="noopener noreferrer"&gt;committed-supabase-key-detection on Kaggle&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The task page shows per-model results, full conversations, and the assertion breakdown for every case. Fake keys, real patterns — steal the cases for your own review checklists.&lt;/p&gt;

&lt;p&gt;(And if you're reading this with a committed Supabase key somewhere in your repo history: rotate it now. It takes 15 minutes, and I promise you're not the only one who's seen it.)&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>58 of 154 public Supabase apps had a working service_role key in git</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:12:40 +0000</pubDate>
      <link>https://dev.to/cekuu35/58-of-154-public-supabase-apps-had-a-working-servicerole-key-in-git-gie</link>
      <guid>https://dev.to/cekuu35/58-of-154-public-supabase-apps-had-a-working-servicerole-key-in-git-gie</guid>
      <description>&lt;h1&gt;
  
  
  58 of 154 public Supabase apps had a working service_role key in git. Here's what that means for yours.
&lt;/h1&gt;

&lt;p&gt;If you build with Lovable, Bolt, Cursor, or any AI coding tool on top of Supabase, this week's scan has a number you should see: &lt;strong&gt;58 of 154 public apps shipped a working &lt;code&gt;service_role&lt;/code&gt; key inside their git history.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That key is the master key for the database. It bypasses Row-Level Security entirely — the same RLS you turned on in the dashboard does not apply to it. Anyone who finds it can run &lt;code&gt;DELETE FROM "user"&lt;/code&gt; over the REST API and the database believes it.&lt;/p&gt;

&lt;p&gt;I did not read a single row of anyone's data. I verified the key decodes to a real project (&lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;ref&lt;/code&gt;, and a &lt;code&gt;service_role&lt;/code&gt; role claim), checked that the project is live, and moved on. That is enough to know the exposure is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening (it is not laziness)
&lt;/h2&gt;

&lt;p&gt;Three causes, in the order I actually saw them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI tools scaffold &lt;code&gt;.env&lt;/code&gt; files with real keys.&lt;/strong&gt; Your AI agent opens the Supabase dashboard to "wire up the database," copies the project URL and keys into &lt;code&gt;.env&lt;/code&gt;, and commits it because the app "won't build" otherwise. The placeholders in memory get replaced by real values — then &lt;code&gt;.gitignore&lt;/code&gt; is never created for &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The file that ships is named like a dev file.&lt;/strong&gt; &lt;code&gt;.env.txt&lt;/code&gt;, &lt;code&gt;env.local&lt;/code&gt;, &lt;code&gt;.env.development&lt;/code&gt; — these often bypass the &lt;code&gt;.gitignore&lt;/code&gt; pattern &lt;code&gt;*.env&lt;/code&gt; because the name is subtly different.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;History keeps the key after the file is deleted.&lt;/strong&gt; Deleting &lt;code&gt;.env&lt;/code&gt; in a new commit is not a fix. The key stays in every previous commit unless you rewrite history.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 10-second check for your own repo
&lt;/h2&gt;

&lt;p&gt;Paste this into supabase.com/dashboard → your project → SQL Editor. It shows every object a &lt;code&gt;service_role&lt;/code&gt; (or anon) key can reach that your RLS policies do not actually protect:&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="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="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;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;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="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="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&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;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="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&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="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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row of that result is readable and writable by anyone holding the public anon key that ships in your browser bundle. If &lt;code&gt;relrowsecurity&lt;/code&gt; is false, Row-Level Security is not even on for that table.&lt;/p&gt;

&lt;p&gt;For git: &lt;code&gt;git log --all --oneline -- .env&lt;/code&gt; shows every commit the file touched. If you see more than the one "deleting it" commit, the key is still in history.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you find one — do this first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rotate the key first (dashboard → Settings → API), &lt;em&gt;then&lt;/em&gt; rewrite history (&lt;code&gt;git filter-repo&lt;/code&gt; or BFG).&lt;/li&gt;
&lt;li&gt;Fix the commit habit that caused it: one line in your AI tool's instructions — "never commit &lt;code&gt;.env&lt;/code&gt;, create &lt;code&gt;.gitignore&lt;/code&gt; entry &lt;code&gt;*.env*&lt;/code&gt; before first launch."&lt;/li&gt;
&lt;li&gt;Re-run the check above on the same project to make sure nothing else is open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every check in that list — RLS coverage, open policies, &lt;code&gt;USING (true)&lt;/code&gt; traps, write-without-read policy conflicts, and the git-history secret scan — I've packaged as a free, read-only, 60-point pre-launch check that runs against your own DB in about 30 seconds and uploads nothing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The scan behind this post ran the same evidence standard: decode the JWT, confirm &lt;code&gt;role == "service_role"&lt;/code&gt;, confirm the project is live. No data was read, no exports were made, nothing was saved. If you want a second pair of eyes on your schema after rotating — policy conflicts, unisolated joins, service_role exposure paths — that is the read-only review I do as paid work, and the email + Gumroad link are in my profile.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase RLS: a small, evidence-first pre-launch check</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Fri, 18 Sep 2026 21:34:19 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-rls-a-small-evidence-first-pre-launch-check-468o</link>
      <guid>https://dev.to/cekuu35/supabase-rls-a-small-evidence-first-pre-launch-check-468o</guid>
      <description>&lt;h1&gt;
  
  
  Supabase RLS: a small, evidence-first pre-launch check
&lt;/h1&gt;

&lt;p&gt;RLS being enabled is only the first check. A useful review asks what an anonymous request, an authenticated user, and a second tenant can actually read or write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five checks worth running
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List every exposed table and confirm RLS is enabled.&lt;/li&gt;
&lt;li&gt;Inspect both &lt;code&gt;USING&lt;/code&gt; and &lt;code&gt;WITH CHECK&lt;/code&gt;; a read policy does not automatically secure inserts or updates.&lt;/li&gt;
&lt;li&gt;Look for policies that are intentionally broad, such as &lt;code&gt;USING (true)&lt;/code&gt;, and confirm they are limited to the correct role and table.&lt;/li&gt;
&lt;li&gt;Review &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions, their &lt;code&gt;search_path&lt;/code&gt;, and their &lt;code&gt;EXECUTE&lt;/code&gt; grants.&lt;/li&gt;
&lt;li&gt;Test the live database with two separate user identities. Confirm that user A cannot read or modify user B's rows, even when the request is made directly against the REST endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example owner-scoped 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;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;orders&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;"orders_select_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;orders&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"orders_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;orders&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="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;Use a service-role key only in a trusted server-side path. Never put it in browser code, migrations, screenshots, or a public issue.&lt;/p&gt;

&lt;p&gt;The free &lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;RLS isolation fixture&lt;/a&gt; demonstrates the two-user negative-test pattern. If you want a human second set of eyes, the &lt;a href="https://cengokurtoglu.gumroad.com/l/supabase-rls-3-table-review" rel="noopener noreferrer"&gt;24-hour, three-table review&lt;/a&gt; is a focused configuration review using sanitized schema and policy SQL only.&lt;/p&gt;

&lt;p&gt;This is a configuration review, not a penetration test or a security certification. Test only systems you own or are explicitly authorized to review.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>October 30, 2026: the Supabase change that will 42501 your next deploy - and the leak that was already there</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:06:58 +0000</pubDate>
      <link>https://dev.to/cekuu35/october-30-2026-the-supabase-change-that-will-42501-your-next-deploy-and-the-leak-that-was-4i3j</link>
      <guid>https://dev.to/cekuu35/october-30-2026-the-supabase-change-that-will-42501-your-next-deploy-and-the-leak-that-was-4i3j</guid>
      <description>&lt;p&gt;On October 30, 2026, Supabase changes what happens to every table you create in an existing project. Not a setting you toggle - a platform default enforcement that lands on every project at once.&lt;/p&gt;

&lt;p&gt;Here is the quiet part nobody warns you about: &lt;strong&gt;the migration you already wrote and shipped is the one that breaks&lt;/strong&gt;, and the leak that matters was probably already there before today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens on October 30
&lt;/h2&gt;

&lt;p&gt;Supabase published this as changelog entry &lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;#45329&lt;/a&gt; on April 28, 2026. The dates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-05-30&lt;/td&gt;
&lt;td&gt;New projects default to opt-in exposure (grant required)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026-10-30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The same rule is enforced on &lt;strong&gt;every existing project&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before the change, a table in &lt;code&gt;public&lt;/code&gt; was automatically granted &lt;code&gt;select, insert, update, delete&lt;/code&gt; to &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt;, and &lt;code&gt;service_role&lt;/code&gt; - reachable through the Data API the moment it existed. After the flip, &lt;strong&gt;new tables&lt;/strong&gt; in &lt;code&gt;public&lt;/code&gt; are reachable only if your migration explicitly &lt;code&gt;GRANT&lt;/code&gt;s the role.&lt;/p&gt;

&lt;p&gt;Existing tables keep their current grants. That is the trap, in two opposite ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1 - your next migration breaks with a 403 that is silent in CI.&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;customer_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;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;integer&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;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&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;Deploy that after October 30 and the first SDK call dies in production with error &lt;code&gt;42501 permission denied for table invoices&lt;/code&gt; - PostgREST short-circuits at the privilege layer before RLS is ever evaluated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2 - you are about to "fix" it by opening the whole database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first search hit for &lt;code&gt;permission denied for table supabase&lt;/code&gt; is the barrel-load answer:&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;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. Your table appears in the API immediately. It also hands anonymous visitors &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; on every table in the database - including and especially the ones you never meant to expose. On a project where RLS is off on even one table, this command is a full database exposure, and it is the exact CVE-2025-48757 class of leak (170 production apps, 303 endpoints, public anon key).&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak that was already there
&lt;/h2&gt;

&lt;p&gt;While you are thinking about October 30, this is the query that finds your current problem:&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="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;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="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;privilege_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'(none)'&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;grants&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;left&lt;/span&gt; &lt;span class="k"&gt;join&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;g&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&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;and&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&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;relname&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&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;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="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&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;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;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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rls_enabled = false&lt;/code&gt; with any &lt;code&gt;anon:...&lt;/code&gt; grant = &lt;strong&gt;every profile in your database returns to a stranger with &lt;code&gt;curl&lt;/code&gt; and your anon key.&lt;/strong&gt; That key ships in your JavaScript bundle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rls_enabled = false&lt;/code&gt; with &lt;code&gt;anon:UPDATE&lt;/code&gt; on top = the same stranger can also rewrite rows.&lt;/li&gt;
&lt;li&gt;The row that hurts looks like &lt;code&gt;profiles | false | anon:SELECT, anon:UPDATE&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the anon key check, one curl:&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="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: YOUR_ANON_KEY"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[]&lt;/code&gt; = RLS is doing its job.&lt;/li&gt;
&lt;li&gt;rows = &lt;strong&gt;anonymous visitors can read this table.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;42501&lt;/code&gt; = the grant did not apply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to ship so both traps are impossible
&lt;/h2&gt;

&lt;p&gt;Make grants part of the migration file, next to the table, next to the 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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;customer_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;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;integer&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;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&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;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- only if genuinely public&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&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;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&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;"users see own invoices"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&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;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grant to &lt;code&gt;authenticated&lt;/code&gt;, not &lt;code&gt;anon&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;grant ... to anon&lt;/code&gt; with RLS off.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;grant all on all tables ... to anon&lt;/code&gt; as the fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest version of the deadline
&lt;/h2&gt;

&lt;p&gt;October 30 does not turn your RLS off. It does not touch existing tables. What it does is stop silently exposing the tables you create from now on - while leaving all the tables you already created exactly as open or closed as they were. The two jobs are separate: &lt;strong&gt;grant hygiene going forward, and an audit of what is already reachable today.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Runnable isolation check: &lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo" rel="noopener noreferrer"&gt;https://github.com/cekuu35/supabase-rls-leak-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free PDF checklist: &lt;a href="https://cengokurtoglu.gumroad.com/l/nextjs-supabase-10-checks-free" rel="noopener noreferrer"&gt;https://cengokurtoglu.gumroad.com/l/nextjs-supabase-10-checks-free&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What to do next (Complete 10-Product CTA Ladder)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quick self-check&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7-Surface Supabase Checklist&lt;/strong&gt; (PDF)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-serve audit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RLS Audit Kit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut bill 50%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Supabase Cost Optimization Report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$99&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant bug&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Multi-tenant RLS Policy Fix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$199&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File upload holes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;File Upload Security&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$199&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe webhook broken?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Lovable Stripe Webhook Rescue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$399&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24-48h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth breaks on domain&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Auth Production Deploy Package&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$499&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge functions wide open&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Edge Function Security Hardening&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$299&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy with confidence&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Supabase Migration Review&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$299&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All-in-one launch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Launch-Ready SaaS Backend Bundle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$599&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;48h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin panel missing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Admin Panel Scaffold&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$399&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing monitoring&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Guard $50/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$50/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Recurring&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Run the audit query above before October 30. The date is the excuse; the leak was the actual news.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase RLS returns rows it shouldn't (or throws 42501): the 6 real causes</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 02 Sep 2026 23:41:43 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-rls-returns-rows-it-shouldnt-or-throws-42501-the-6-real-causes-1ggo</link>
      <guid>https://dev.to/cekuu35/supabase-rls-returns-rows-it-shouldnt-or-throws-42501-the-6-real-causes-1ggo</guid>
      <description>&lt;p&gt;If your Supabase app either &lt;strong&gt;shows data it shouldn't&lt;/strong&gt; or blocks users with&lt;br&gt;
&lt;code&gt;42501 - new row violates row-level security policy&lt;/code&gt;, it's almost always one of&lt;br&gt;
six causes. Work down the list; each has a 10-second check.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. RLS was never enabled (the AI-tool trap)
&lt;/h2&gt;

&lt;p&gt;Supabase enables RLS automatically only for tables created in the &lt;strong&gt;Table&lt;br&gt;
Editor&lt;/strong&gt;. Tables created via SQL editor, migrations, Prisma, or an AI coding&lt;br&gt;
agent ship wide open.&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;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;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="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="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 row of that result is reachable by anyone holding your public anon key.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RLS enabled, but no policy matches ? everything denied
&lt;/h2&gt;

&lt;p&gt;Postgres defaults to deny. If your table suddenly returns empty arrays after&lt;br&gt;
you "turned security on", this is why: &lt;code&gt;relrowsecurity = true&lt;/code&gt; with zero&lt;br&gt;
applicable policies denies all rows to all roles.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The &lt;code&gt;USING (true)&lt;/code&gt; policy that looks like security
&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="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;This satisfies "we have RLS" while exposing every row. Search your 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;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&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;schemame&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'(true)'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&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;Each hit needs a human decision: is this table &lt;em&gt;meant&lt;/em&gt; to be public?&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Missing WITH CHECK on writes
&lt;/h2&gt;

&lt;p&gt;A SELECT-only mindset leaks into INSERT/UPDATE policies without a &lt;code&gt;WITH&lt;br&gt;
CHECK&lt;/code&gt; clause - and Postgres then derives an implicit check from &lt;code&gt;USING&lt;/code&gt;,&lt;br&gt;
which fails in confusing ways (especially on UPDATEs that rewrite the whole&lt;br&gt;
row). Always write both halves 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 update 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;items&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="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Dependent-policy lockout ("my admin policy is perfect but the table is empty")
&lt;/h2&gt;

&lt;p&gt;Policies that subquery another table (&lt;code&gt;exists (select 1 from profiles where ...)&lt;/code&gt;)&lt;br&gt;
fail silently when the &lt;em&gt;inner&lt;/em&gt; table's own RLS hides its rows from the calling&lt;br&gt;
user. Your admin sees nothing; a bare &lt;code&gt;true&lt;/code&gt; policy "fixes" it - which is how&lt;br&gt;
teams get talked into cause #3. Give the inner table a proper read-own-row&lt;br&gt;
policy first.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Views, SECURITY DEFINER functions, and Storage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Post-15 views run as their owner by default ? can expose rows the table
policies hide. Use &lt;code&gt;security_invoker = true&lt;/code&gt; views.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;security definer&lt;/code&gt; functions bypass RLS unless they set it themselves; pin
&lt;code&gt;set search_path&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Storage buckets have their own &lt;code&gt;storage.objects&lt;/code&gt; policies - a private DB
behind a public bucket is still public.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Prove your whole schema in 60 seconds:&lt;/strong&gt; I put nine read-only queries that&lt;br&gt;
cover causes 1-6 into a free SQL file you run in your own SQL editor - nothing&lt;br&gt;
leaves your database:&lt;br&gt;
&lt;a href="https://github.com/cekuu35/supabase-rls-leak-demo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rls_kit" rel="noopener noreferrer"&gt;github.com/cekuu35/supabase-rls-leak-demo ? audit/rls-audit.sql&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same repo contains a red/green test suite demonstrating the leak and the&lt;br&gt;
one-file fix. If you want the full guided workflow (write-side checks,&lt;br&gt;
role-simulation harness, remediation templates), that's the&lt;br&gt;
&lt;a href="https://cengokurtoglu.gumroad.com/l/supabase-rls-audit-kit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rls_kit" rel="noopener noreferrer"&gt;Supabase RLS Audit Kit&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$29, runs entirely against your own catalogs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: both links are mine. The audit queries and demo are free and MIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The service_role Key Hiding in Your .mcp.json — the AI-Vibe-Coder Secret Leak</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:45:24 +0000</pubDate>
      <link>https://dev.to/cekuu35/the-servicerole-key-hiding-in-your-mcpjson-the-ai-vibe-coder-secret-leak-562l</link>
      <guid>https://dev.to/cekuu35/the-servicerole-key-hiding-in-your-mcpjson-the-ai-vibe-coder-secret-leak-562l</guid>
      <description>&lt;p&gt;A year ago, the classic Supabase leak was a &lt;code&gt;.env&lt;/code&gt; file committed to a public repo. Everyone learned to gitignore &lt;code&gt;.env&lt;/code&gt;. Scanners learned to flag it. We mostly moved on.&lt;/p&gt;

&lt;p&gt;But the way people build changed. Now you wire Supabase into Cursor, Claude Code, Windsurf, or VS Code through an MCP server, paste a key into a JSON config so your AI assistant can talk to your database, and keep shipping. That config file &lt;em&gt;feels&lt;/em&gt; like an editor setting, not a secret. So it gets committed. And it's carrying the one credential that makes Row Level Security irrelevant.&lt;/p&gt;

&lt;p&gt;This post is about that new leak surface — where it hides, why it's worse than a &lt;code&gt;.env&lt;/code&gt; slip, and the exact order you fix it in. (Order matters more than you'd think.)&lt;/p&gt;

&lt;h2&gt;
  
  
  First, get the two keys straight
&lt;/h2&gt;

&lt;p&gt;Supabase gives you two classes of API key, and the whole security story hinges on telling them apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publishable / anon keys&lt;/strong&gt; (&lt;code&gt;sb_publishable_...&lt;/code&gt;, or the legacy &lt;code&gt;anon&lt;/code&gt; JWT) are &lt;strong&gt;public by design&lt;/strong&gt;. They are meant to ship in your browser bundle. Anyone can read them out of your JavaScript, and that is fine — because every request they make is still filtered through Row Level Security. The key identifies your project; RLS decides what that request is allowed to see. A publishable key with good RLS behind it is not a leak. It's the front door, and the front door is supposed to be visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret / service_role keys&lt;/strong&gt; (&lt;code&gt;sb_secret_...&lt;/code&gt;, or the legacy &lt;code&gt;service_role&lt;/code&gt; JWT) are the opposite. Straight from the Supabase docs, they provide &lt;em&gt;full access&lt;/em&gt; to your project's data, &lt;strong&gt;bypassing Row Level Security entirely&lt;/strong&gt;. This key is a backend-only credential — for servers, Edge Functions, admin panels. It does not care what policies you wrote. It reads every row in every table, writes anything, deletes anything.&lt;/p&gt;

&lt;p&gt;So here's the painful irony: developers panic when they realize their anon key is visible in the browser (it's fine), while the key that actually ends up in public places is the service_role key. And the newest place it ends up is your AI tooling config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually hides now
&lt;/h2&gt;

&lt;p&gt;When you connect Supabase to an AI coding assistant, the credential goes into a JSON file that lives &lt;em&gt;inside your repo&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; (Claude Code, project root)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.cursor/mcp.json&lt;/code&gt; (Cursor)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.vscode/mcp.json&lt;/code&gt; (VS Code)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.codeium/windsurf/mcp_config.json&lt;/code&gt; (Windsurf)&lt;/li&gt;
&lt;li&gt;any &lt;code&gt;mcp.json&lt;/code&gt; / &lt;code&gt;mcp-config.json&lt;/code&gt; a tutorial told you to create&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical entry looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"supabase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@supabase/mcp-server-supabase"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SUPABASE_ACCESS_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_0a1b2c3d..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"SUPABASE_SERVICE_ROLE_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sb_secret_9f8e7d..."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are actually &lt;strong&gt;three&lt;/strong&gt; different disasters that show up in these files:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sb_secret_...&lt;/code&gt; / service_role&lt;/strong&gt; — bypasses RLS, full data access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Supabase personal access token (&lt;code&gt;sbp_...&lt;/code&gt;)&lt;/strong&gt; — controls your whole account through the Management API: it can list projects, run SQL, even create or pause projects. This is arguably &lt;em&gt;worse&lt;/em&gt; than a single project's service_role key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Postgres connection string&lt;/strong&gt; — &lt;code&gt;postgresql://postgres:PASSWORD@db.ref.supabase.co:5432/postgres&lt;/code&gt;. That's your database password in plaintext, i.e. direct superuser access to the box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three routinely land in MCP config because that's exactly what the quickstarts tell you to paste in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than the old &lt;code&gt;.env&lt;/code&gt; mistake
&lt;/h2&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't look like a secret.&lt;/strong&gt; &lt;code&gt;.env&lt;/code&gt; screams "credentials." &lt;code&gt;.cursor/mcp.json&lt;/code&gt; reads like "my editor preferences." People commit editor config without a second thought — and often &lt;em&gt;want&lt;/em&gt; to commit it so teammates get the same setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tooling gap.&lt;/strong&gt; Plenty of repos have &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt; and nothing else. &lt;code&gt;.mcp.json&lt;/code&gt;, &lt;code&gt;.cursor/&lt;/code&gt;, and &lt;code&gt;.vscode/&lt;/code&gt; are frequently tracked on purpose. Your existing ignore rules do not cover them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's already in your git history.&lt;/strong&gt; This is the part people miss. Even if you delete the key from the current file and commit the fix, the secret still sits in a past commit. &lt;code&gt;git log&lt;/code&gt;, any fork, any clone, GitHub's cached views, and every secret-scanning bot that already crawled your repo still have it. Deleting a secret from the latest commit does &lt;strong&gt;not&lt;/strong&gt; un-leak it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself in 30 seconds
&lt;/h2&gt;

&lt;p&gt;From your repo root:&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;# Is a secret sitting in your history right now?&lt;/span&gt;
git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s1"&gt;'sb_secret_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;head
&lt;/span&gt;git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s1"&gt;'service_role'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;head
&lt;/span&gt;git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s1"&gt;'sbp_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# Are the AI config files even tracked?&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;'(^|/)(\.mcp\.json|\.cursor/|\.vscode/mcp\.json)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a real audit, run a proper scanner over the whole history — &lt;code&gt;gitleaks detect&lt;/code&gt; or &lt;code&gt;trufflehog git file://.&lt;/code&gt; will find these across every commit, not just HEAD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it in the right order
&lt;/h2&gt;

&lt;p&gt;If you found something, do these steps &lt;strong&gt;in this order&lt;/strong&gt;. The order is the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rotate first — before anything else.&lt;/strong&gt; In the Supabase dashboard: for API keys, revoke the exposed secret key and create a new one; for a personal access token, revoke it under Account → Access Tokens; for a leaked DB password, reset it under Project Settings → Database. Rotation is what actually protects you, because it invalidates the leaked credential everywhere at once. Cleaning git history without rotating is theater — the secret was already public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Then purge it from history.&lt;/strong&gt; Now that the key is dead, scrub the value so bots stop flagging your repo: &lt;code&gt;git filter-repo --replace-text&lt;/code&gt; or the BFG Repo-Cleaner, then force-push. (Coordinate with collaborators — this rewrites history.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stop it happening again.&lt;/strong&gt; Add the config paths to &lt;code&gt;.gitignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Commit a &lt;code&gt;.mcp.json.example&lt;/code&gt; with &lt;code&gt;${SUPABASE_ACCESS_TOKEN}&lt;/code&gt; placeholders instead of real values, and keep the real ones in your shell environment or a secrets manager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Reduce the blast radius.&lt;/strong&gt; The Supabase MCP server supports &lt;code&gt;--read-only&lt;/code&gt; and &lt;code&gt;--project-ref&lt;/code&gt; flags. Scope the token to a single project and read-only access so that even a future leak can't write or delete. Give your AI assistant the least privilege that still lets it help.&lt;/p&gt;

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

&lt;p&gt;The anon key in your browser was never the problem. The service_role key in your &lt;code&gt;.cursor/mcp.json&lt;/code&gt; is. RLS is a genuinely strong wall — but service_role walks straight through it, and AI tooling has quietly created a brand-new way to commit that key to a public repo without realizing it.&lt;/p&gt;

&lt;p&gt;So: audit your history, rotate anything you find, &lt;em&gt;then&lt;/em&gt; clean up. In that order.&lt;/p&gt;




&lt;p&gt;If you want to actually &lt;em&gt;see&lt;/em&gt; this happen end to end, I put together a small reproducible demo — a repo with a service_role key wired into an MCP config, and a walkthrough of exactly what an attacker can pull once RLS is out of the picture and how the rotate-then-purge fix plays out: &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 free, no signup.&lt;/p&gt;

&lt;p&gt;And if you'd rather run a full pass over your own project — RLS policies, key placement, and config-file leaks like the one above — I keep a paid &lt;strong&gt;&lt;a href="https://cengokurtoglu.gumroad.com/l/supabase-rls-audit-kit" rel="noopener noreferrer"&gt;Supabase RLS Audit Kit&lt;/a&gt;&lt;/strong&gt; ($29) with the checklist and test scripts I use. Totally optional; the demo repo above already covers the leak in this post.&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>Sat, 15 Aug 2026 00:41:57 +0000</pubDate>
      <link>https://dev.to/cekuu35/the-supabase-security-checklist-to-run-before-you-launch-23k7</link>
      <guid>https://dev.to/cekuu35/the-supabase-security-checklist-to-run-before-you-launch-23k7</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 -&amp;gt; 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>postgres</category>
      <category>security</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>Sat, 15 Aug 2026 00:37:50 +0000</pubDate>
      <link>https://dev.to/cekuu35/nextpublic-and-supabase-which-env-variables-are-safe-to-expose-2h7</link>
      <guid>https://dev.to/cekuu35/nextpublic-and-supabase-which-env-variables-are-safe-to-expose-2h7</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>Sat, 15 Aug 2026 00:33:34 +0000</pubDate>
      <link>https://dev.to/cekuu35/you-committed-env-to-github-what-to-rotate-supabase-nextjs-1a9l</link>
      <guid>https://dev.to/cekuu35/you-committed-env-to-github-what-to-rotate-supabase-nextjs-1a9l</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 -&amp;gt; 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 -&amp;gt; Database -&amp;gt; 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 -&amp;gt; API keys -&amp;gt; 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>Sat, 15 Aug 2026 00:30:34 +0000</pubDate>
      <link>https://dev.to/cekuu35/test-your-supabase-rls-policies-locally-a-free-sql-harness-ide</link>
      <guid>https://dev.to/cekuu35/test-your-supabase-rls-policies-locally-a-free-sql-harness-ide</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>webdev</category>
    </item>
    <item>
      <title>Supabase RLS with auth.uid() not working? Causes and fixes</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Sat, 15 Aug 2026 00:27:20 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-rls-with-authuid-not-working-causes-and-fixes-gld</link>
      <guid>https://dev.to/cekuu35/supabase-rls-with-authuid-not-working-causes-and-fixes-gld</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>Sat, 15 Aug 2026 00:23:23 +0000</pubDate>
      <link>https://dev.to/cekuu35/is-my-supabase-anon-key-exposed-whats-actually-safe-and-what-isnt-55m0</link>
      <guid>https://dev.to/cekuu35/is-my-supabase-anon-key-exposed-whats-actually-safe-and-what-isnt-55m0</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>
  </channel>
</rss>
