<?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>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>
    <item>
      <title>Your AI Coding Tool Is Committing Your Supabase service_role Key</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Fri, 14 Aug 2026 15:15:43 +0000</pubDate>
      <link>https://dev.to/cekuu35/your-ai-coding-tool-is-committing-your-supabase-servicerole-key-2nn2</link>
      <guid>https://dev.to/cekuu35/your-ai-coding-tool-is-committing-your-supabase-servicerole-key-2nn2</guid>
      <description>&lt;p&gt;If you've been vibe-coding a Supabase app with Claude Code, Cursor, aider, or an MCP setup, there's a leak vector almost nobody is talking about yet: your AI tool's own config files. They quietly capture your &lt;code&gt;service_role&lt;/code&gt; key and commit it to your repo, where bots scrape it within minutes of a push.&lt;/p&gt;

&lt;p&gt;Here's exactly how it happens, how to check your whole repo (including history), and how to actually fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: you have two keys, and only one is a secret
&lt;/h2&gt;

&lt;p&gt;Supabase hands every project two keys, and they are not equal.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;anon / publishable key&lt;/strong&gt; is &lt;strong&gt;public by design&lt;/strong&gt;. It ships in your browser bundle. It is &lt;em&gt;not&lt;/em&gt; a secret, and you do &lt;em&gt;not&lt;/em&gt; rotate it. Row Level Security (RLS) is what protects your data when someone hits your API with this key.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;service_role key&lt;/strong&gt; is a &lt;strong&gt;server-only secret that bypasses RLS entirely&lt;/strong&gt; — full read/write/delete on every table, plus Storage. It must never reach a browser, a public repo, or any client-side config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two key &lt;em&gt;formats&lt;/em&gt; in the wild, so know both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Legacy JWT keys&lt;/strong&gt; — long tokens starting with &lt;code&gt;eyJ&lt;/code&gt;. Decode the middle segment to read the role. JWT payloads are base64*&lt;em&gt;url&lt;/em&gt;* and unpadded, so a naive &lt;code&gt;base64 -d&lt;/code&gt; often errors and prints nothing — which can look like a false all-clear. Normalize and pad first:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"paste-your-key-here"&lt;/span&gt;
&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&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="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;="&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
&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;$p&lt;/span&gt;&lt;span class="s2"&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;# macOS: if base64 -d fails, use base64 -D&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"role":"anon"&lt;/code&gt; is fine anywhere. &lt;code&gt;"role":"service_role"&lt;/code&gt; committed anywhere public is a critical incident.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New keys&lt;/strong&gt; (Supabase's 2025 format) are &lt;em&gt;not&lt;/em&gt; JWTs — the prefix tells you everything: &lt;code&gt;sb_publishable_...&lt;/code&gt; is public, &lt;code&gt;sb_secret_...&lt;/code&gt; is the secret. No decoding needed. A committed &lt;code&gt;sb_secret_&lt;/code&gt; is the emergency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the AI tool leaks it
&lt;/h2&gt;

&lt;p&gt;The pattern is always the same: your tool needs the key to do admin work, writes it into a config file, and that file gets committed because nobody added it to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code&lt;/strong&gt; — &lt;code&gt;.claude/settings.local.json&lt;/code&gt;. Approve an allow-listed bash command once and the literal command is saved. If you ever ran a &lt;code&gt;curl&lt;/code&gt; with an &lt;code&gt;apikey: &amp;lt;service_role&amp;gt;&lt;/code&gt; header, that full key now lives in the allow list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor / MCP&lt;/strong&gt; — &lt;code&gt;.cursor/mcp.json&lt;/code&gt; or &lt;code&gt;.mcp.json&lt;/code&gt;. MCP servers take an &lt;code&gt;env&lt;/code&gt; block, and the Supabase MCP server wants &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; right there in plaintext JSON.&lt;/li&gt;
&lt;li&gt;Same story for &lt;code&gt;.continue&lt;/code&gt;, aider config, and &lt;code&gt;.vscode/settings.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why is this catastrophic? Because service_role skips RLS. Whoever finds the key doesn't need to break your policies — the key &lt;em&gt;is&lt;/em&gt; the break. They read every user's rows, edit balances, delete tables, and empty your Storage buckets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where service_role keys leak: the full checklist
&lt;/h2&gt;

&lt;p&gt;Beyond AI config, these are the usual suspects. Run each from your repo root.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. AI coding-tool config&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;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'service_role|SUPABASE_SERVICE|sb_secret_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'.claude/*'&lt;/span&gt; &lt;span class="s1"&gt;'.cursor/*'&lt;/span&gt; &lt;span class="s1"&gt;'.mcp.json'&lt;/span&gt; &lt;span class="s1"&gt;'.continue/*'&lt;/span&gt; &lt;span class="s1"&gt;'.aider*'&lt;/span&gt; &lt;span class="s1"&gt;'.vscode/settings.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Committed &lt;code&gt;.env&lt;/code&gt; variants&lt;/strong&gt; — &lt;code&gt;.env&lt;/code&gt; is usually ignored, its siblings often aren't (&lt;code&gt;.env.local&lt;/code&gt;, &lt;code&gt;.env.production&lt;/code&gt;, &lt;code&gt;.env.staging&lt;/code&gt;, &lt;code&gt;.env.test&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. The trailing-space &lt;code&gt;.env&lt;/code&gt; trick&lt;/strong&gt; — a &lt;code&gt;.gitignore&lt;/code&gt; rule for &lt;code&gt;.env&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; match a file literally named &lt;code&gt;.env&lt;/code&gt; (trailing space). It slips right through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-files | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s1"&gt;'\.env '&lt;/span&gt;   &lt;span class="c"&gt;# note the space&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. The &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; / &lt;code&gt;VITE_&lt;/code&gt; / &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; trap&lt;/strong&gt; — these prefixes tell your bundler to inline the value into the &lt;strong&gt;client&lt;/strong&gt; bundle. &lt;code&gt;NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; ships your secret to every visitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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_).*(SERVICE_ROLE|SERVICE_KEY)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Deploy config&lt;/strong&gt; — &lt;code&gt;vercel.json&lt;/code&gt; env blocks, &lt;code&gt;netlify.toml&lt;/code&gt;, &lt;code&gt;wrangler.toml&lt;/code&gt;/&lt;code&gt;.dev.vars&lt;/code&gt;, and &lt;code&gt;railway&lt;/code&gt;/&lt;code&gt;render&lt;/code&gt;/&lt;code&gt;fly&lt;/code&gt; config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;'service_role|SERVICE_ROLE|sb_secret_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  vercel.json netlify.toml wrangler.toml &lt;span class="s1"&gt;'.dev.vars'&lt;/span&gt; &lt;span class="s1"&gt;'render.yaml'&lt;/span&gt; &lt;span class="s1"&gt;'fly.toml'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;6. CI and test config&lt;/strong&gt; — GitHub Actions with inline secrets, &lt;code&gt;cypress.env.json&lt;/code&gt;, &lt;code&gt;docker-compose&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;'service_role|SERVICE_ROLE|sb_secret_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'.github/workflows/*'&lt;/span&gt; &lt;span class="s1"&gt;'docker-compose*.yml'&lt;/span&gt; Dockerfile
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;'cypress.env.json|\.env.test'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;7. Seed / migrate / admin scripts and committed SQL/JSON dumps&lt;/strong&gt; — these legitimately need service_role, then get committed with the literal inlined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;'service_role|sb_secret_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'scripts/*'&lt;/span&gt; &lt;span class="s1"&gt;'seed*'&lt;/span&gt; &lt;span class="s1"&gt;'supabase/seed*'&lt;/span&gt; &lt;span class="s1"&gt;'*.sql'&lt;/span&gt; &lt;span class="s1"&gt;'*.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One important gotcha:&lt;/strong&gt; a leaked &lt;em&gt;legacy&lt;/em&gt; key is an &lt;code&gt;eyJ...&lt;/code&gt; JWT whose payload does &lt;strong&gt;not&lt;/strong&gt; contain the plaintext string &lt;code&gt;service_role&lt;/code&gt;. If a key was inlined bare — &lt;code&gt;createClient(url, 'eyJ...')&lt;/code&gt; — or hidden behind a generic name like &lt;code&gt;SUPABASE_KEY=&lt;/code&gt;, every &lt;code&gt;grep service_role&lt;/code&gt; above misses it. So also sweep for the token itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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;'eyJhbGci|sb_secret_'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Check your whole repo, including history
&lt;/h2&gt;

&lt;p&gt;Deleting a file today does nothing — a committed secret lives in &lt;strong&gt;git history forever&lt;/strong&gt; and is recoverable from any past commit. Search history, not just the working tree. The &lt;code&gt;-G&lt;/code&gt; flag matches the token by regex (unlike &lt;code&gt;-S&lt;/code&gt;, which only fires when a string's count changes):&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;-p&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s1"&gt;'eyJhbGci|sb_secret_'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'eyJhbGci|sb_secret_'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a large or long-lived repo, run a dedicated scanner instead — &lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;gitleaks&lt;/a&gt; or trufflehog also catch dangling commits the command above can miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitleaks detect &lt;span class="nt"&gt;--source&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--redact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer not to eyeball this? A free repro plus read-only audit SQL that flags committed-key patterns, RLS-off tables, and anon-open policies lives here: &lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;. Clone it, point it at your project, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Found one? Rotate first, then purge history
&lt;/h2&gt;

&lt;p&gt;Order matters. Rotation is what actually kills the attacker's access; purging keeps the old value out of clones and stops the next leak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rotate the key.&lt;/strong&gt; Supabase Dashboard → &lt;strong&gt;Settings → API&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New keys:&lt;/strong&gt; revoke and reissue the &lt;code&gt;sb_secret_&lt;/code&gt; key independently — the publishable key and user sessions are untouched. Takes 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy keys:&lt;/strong&gt; there's no isolated "roll service_role" button. Rotating regenerates the shared JWT secret, which &lt;strong&gt;also invalidates the anon key and logs out every user&lt;/strong&gt;. Plan for it, then redeploy clients with the new anon key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy the new value into your server environment before touching history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Purge it from history.&lt;/strong&gt; Using &lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;git-filter-repo&lt;/a&gt; (the tool Git now recommends). Put the leaked string in a file so it's scrubbed from every commit:&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;# expressions.txt&lt;/span&gt;
literal:eyJhbGciOi...your-leaked-key...&lt;span class="o"&gt;==&amp;gt;&lt;/span&gt;REMOVED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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
git filter-repo &lt;span class="nt"&gt;--replace-text&lt;/span&gt; expressions.txt
git remote add origin &amp;lt;your-repo-url&amp;gt;   &lt;span class="c"&gt;# filter-repo drops the remote by default&lt;/span&gt;
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
git push &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;Prefer BFG? Note the format differs — &lt;strong&gt;no &lt;code&gt;literal:&lt;/code&gt; prefix&lt;/strong&gt;, and remove the file from HEAD first (BFG won't touch the tip commit):&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;# bfg-replacements.txt  →  one line:  eyJhbGciOi...your-leaked-key...==&amp;gt;REMOVED&lt;/span&gt;
git &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;--cached&lt;/span&gt; path/to/leaked-file &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"remove secret"&lt;/span&gt;
bfg &lt;span class="nt"&gt;--replace-text&lt;/span&gt; bfg-replacements.txt
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;
git push &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forks and open PRs keep their own copies, so rotation in step 1 is what truly protects you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock it down so it can't recur
&lt;/h2&gt;

&lt;p&gt;Fix &lt;code&gt;.gitignore&lt;/code&gt; carefully — a bare &lt;code&gt;.env*&lt;/code&gt; also swallows &lt;code&gt;.env.example&lt;/code&gt;, which you usually &lt;em&gt;want&lt;/em&gt; committed:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;And keep the service_role key strictly in server code (route handlers, server actions, edge functions) with a bare &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; name — never a public framework prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget RLS itself (defence in depth)
&lt;/h2&gt;

&lt;p&gt;Even with the secret safe, your anon key is public — so any table with &lt;strong&gt;RLS disabled&lt;/strong&gt; or a permissive &lt;code&gt;USING (true)&lt;/code&gt; policy is exposed to the whole internet. A few facts people mix up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt; filters which existing rows a role can &lt;em&gt;see&lt;/em&gt; (and scans for UPDATE/DELETE); &lt;code&gt;WITH CHECK&lt;/code&gt; validates rows being &lt;em&gt;inserted or updated&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;USING (true)&lt;/code&gt; SELECT policy makes a table world-&lt;strong&gt;readable&lt;/strong&gt;; it only becomes world-&lt;strong&gt;writable&lt;/strong&gt; if a permissive &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt; (or &lt;code&gt;FOR ALL&lt;/code&gt;) policy also exists.&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; to touch data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turn RLS on for every table in &lt;code&gt;public&lt;/code&gt; and scope policies to &lt;code&gt;auth.uid()&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;That's the whole loop: know your two keys, sweep working tree &lt;em&gt;and&lt;/em&gt; history for &lt;code&gt;eyJ&lt;/code&gt;/&lt;code&gt;sb_secret_&lt;/code&gt;, rotate then purge, lock down RLS. If you want to go deeper, I keep a $29 &lt;strong&gt;RLS Audit Kit&lt;/strong&gt; — a longer checklist covering policy edge cases, Storage rules, and the leak spots above — but the free demo repo and the commands here will catch the vast majority of real leaks.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase RLS Not Working? Fix It by Symptom (Correct SQL Inside)</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:22:55 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-rls-not-working-fix-it-by-symptom-correct-sql-inside-4gd4</link>
      <guid>https://dev.to/cekuu35/supabase-rls-not-working-fix-it-by-symptom-correct-sql-inside-4gd4</guid>
      <description>&lt;p&gt;Your policy looks right, the SQL editor says it works, but the JS client returns &lt;code&gt;[]&lt;/code&gt;. Or an insert throws &lt;code&gt;new row violates row-level security policy&lt;/code&gt;. Before you rewrite anything, work this checklist top to bottom. Almost every "Supabase RLS not working" report is one of five things, and the first one is the culprit far more often than people expect.&lt;/p&gt;

&lt;p&gt;Two ideas fix half of these before you write any SQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A row is reachable only when the requesting role has BOTH a matching policy AND the table-level GRANT.&lt;/strong&gt; Public-schema tables are exposed to &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; through PostgREST's default grants; RLS + policies then decide what each role can actually do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt; filters existing rows&lt;/strong&gt; (SELECT, and which rows UPDATE/DELETE may touch). &lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt; validates new row values&lt;/strong&gt; (INSERT, and the post-image of UPDATE). INSERT has &lt;code&gt;WITH CHECK&lt;/code&gt; only. If you omit &lt;code&gt;WITH CHECK&lt;/code&gt; on UPDATE/ALL, Postgres reuses &lt;code&gt;USING&lt;/code&gt; as the check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The #1 cause: role mismatch between test and runtime
&lt;/h2&gt;

&lt;p&gt;RLS decisions depend entirely on &lt;strong&gt;which database role makes the request&lt;/strong&gt; and &lt;strong&gt;what's in the JWT&lt;/strong&gt;. The trap is that your SQL-editor test and your real app run as different identities.&lt;/p&gt;

&lt;p&gt;In the SQL editor you're &lt;code&gt;postgres&lt;/code&gt;, which — like &lt;code&gt;service_role&lt;/code&gt; — &lt;strong&gt;bypasses RLS entirely&lt;/strong&gt; and never even runs your policies. So you try to simulate a user:&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;set&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;set role authenticated&lt;/code&gt; alone leaves &lt;code&gt;request.jwt.claims&lt;/code&gt; &lt;strong&gt;unset&lt;/strong&gt;, so &lt;code&gt;auth.uid()&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt; and any policy like &lt;code&gt;user_id = auth.uid()&lt;/code&gt; silently matches nothing. You conclude "my policy is broken" when your &lt;em&gt;test&lt;/em&gt; was broken.&lt;/p&gt;

&lt;p&gt;Meanwhile the JS client sends a real signed JWT (role &lt;code&gt;authenticated&lt;/code&gt;) when there's a session, or hits the DB as &lt;code&gt;anon&lt;/code&gt; when there isn't. Three different worlds. Verify the real one before touching a policy.&lt;/p&gt;

&lt;p&gt;To simulate correctly, you must set the claims — and &lt;code&gt;set local&lt;/code&gt; only lives inside a transaction, so wrap 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;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;'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="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="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":"&amp;lt;a-real-user-uuid&amp;gt;","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="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="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;-- now returns the uuid + authenticated&lt;/span&gt;
&lt;span class="k"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running those as three separate statements will &lt;em&gt;not&lt;/em&gt; work — each &lt;code&gt;set_config(..., true)&lt;/code&gt; is local to its own implicit transaction and is gone by the next line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: "works in the editor, empty/failing in the app" (or vice versa)
&lt;/h2&gt;

&lt;p&gt;Stop guessing and print the real identity from the real request path. The JS client can't &lt;code&gt;select auth.uid()&lt;/code&gt; off a normal table query, so expose it through a tiny function once:&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;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whoami&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&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="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whoami&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then call &lt;code&gt;await supabase.rpc('whoami')&lt;/code&gt; from the app. If &lt;code&gt;uid&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; where you expected a logged-in user, the client has no valid session — you're actually &lt;code&gt;anon&lt;/code&gt;. Confirm the client uses the anon/publishable key and that &lt;code&gt;getSession()&lt;/code&gt; returns a user.&lt;/p&gt;

&lt;p&gt;(Side note that saves reputations: the anon/publishable key is &lt;strong&gt;public by design&lt;/strong&gt; — it ships in the browser bundle, it is not a secret, and it does &lt;strong&gt;not&lt;/strong&gt; need rotating. The &lt;code&gt;service_role&lt;/code&gt; key is the one that must never reach a browser, because it bypasses RLS completely.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: "select returns [] or null, no error"
&lt;/h2&gt;

&lt;p&gt;An empty read is almost never a query bug. With RLS enabled, &lt;strong&gt;no matching SELECT policy = deny&lt;/strong&gt;, and reads don't error — they silently return nothing. Enabling RLS with zero policies denies everything.&lt;/p&gt;

&lt;p&gt;Check what policies actually cover reads. Note &lt;code&gt;cmd&lt;/code&gt; can be &lt;code&gt;SELECT&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; &lt;code&gt;ALL&lt;/code&gt; — a &lt;code&gt;FOR ALL&lt;/code&gt; policy covers SELECT too, so don't test for &lt;code&gt;SELECT&lt;/code&gt; alone:&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;'posts'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing has &lt;code&gt;cmd in ('SELECT','ALL')&lt;/code&gt; for your role, add 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;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own posts"&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;posts&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap it as &lt;code&gt;(select auth.uid())&lt;/code&gt; — the subselect lets Postgres evaluate it once per query instead of once per row, a real win on large tables. A genuinely public table still needs an explicit &lt;code&gt;to anon, authenticated using (true)&lt;/code&gt;; no policy means no rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 3: PGRST116 right after a successful insert
&lt;/h2&gt;

&lt;p&gt;The insert &lt;em&gt;worked&lt;/em&gt;, yet the client throws &lt;code&gt;PGRST116&lt;/code&gt;. Here's the exact mechanism: in supabase-js v2 an insert returns nothing unless you chain &lt;code&gt;.select()&lt;/code&gt;. When you do &lt;code&gt;.insert(payload).select().single()&lt;/code&gt;, the write commits, but the follow-up read is filtered by your &lt;strong&gt;SELECT&lt;/strong&gt; policy — so &lt;code&gt;.single()&lt;/code&gt; sees 0 rows and raises PGRST116 ("JSON object requested, 0 rows returned").&lt;/p&gt;

&lt;p&gt;The fix is a SELECT policy that lets writers see their own row (the Symptom 2 policy usually covers it). If you don't need the row back, just don't call &lt;code&gt;.select()&lt;/code&gt; after the insert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 4: "new row violates row-level security policy" on insert
&lt;/h2&gt;

&lt;p&gt;This exact string is a &lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt; failure&lt;/strong&gt; — Postgres evaluated your new row against the check and it returned false. (A &lt;em&gt;missing GRANT&lt;/em&gt; surfaces differently, as &lt;code&gt;permission denied for table posts&lt;/code&gt;; don't confuse the two.) The usual cause: your policy requires the owner column to equal the current user, but you're not authenticated or you're not setting that column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own posts"&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;posts&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;Verify (1) you're actually authenticated (Symptom 1), and (2) your insert sets &lt;code&gt;user_id&lt;/code&gt; — explicitly, or via a column &lt;code&gt;default auth.uid()&lt;/code&gt;. Remember INSERT has no &lt;code&gt;USING&lt;/code&gt;; a &lt;code&gt;USING&lt;/code&gt; clause on an insert policy does nothing.&lt;/p&gt;

&lt;p&gt;If instead you hit &lt;code&gt;permission denied for table&lt;/code&gt;, restore the grant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posts&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;h2&gt;
  
  
  Symptom 5: "anon can still read or write when it shouldn't"
&lt;/h2&gt;

&lt;p&gt;Two root causes, in order of likelihood. &lt;strong&gt;First, RLS may never have been enabled&lt;/strong&gt; on the table — the single most common real-world Supabase leak. The default PostgREST grant alone exposes full CRUD, no policy required. Always confirm it's 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;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;'posts'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- relrowsecurity must be true&lt;/span&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;posts&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;&lt;strong&gt;Second, an over-permissive policy.&lt;/strong&gt; Permissive policies (the default) &lt;strong&gt;OR&lt;/strong&gt; together, so one leftover &lt;code&gt;to anon using (true)&lt;/code&gt; or &lt;code&gt;with check (true)&lt;/code&gt; overrides every stricter policy beside it. Scan the &lt;code&gt;pg_policies&lt;/code&gt; output from Symptom 2 for any &lt;code&gt;true&lt;/code&gt; whose &lt;code&gt;roles&lt;/code&gt; include &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;public&lt;/code&gt;, and drop it — or convert a guardrail to &lt;strong&gt;restrictive&lt;/strong&gt; (restrictive policies AND in; one false blocks):&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;"tenant guard"&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;posts&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;restrictive&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&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;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tenant_id'&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;One last landmine: never &lt;code&gt;SELECT&lt;/code&gt; from a table &lt;em&gt;inside a policy on that same table&lt;/em&gt; — it re-triggers the policy and recurses infinitely. Push membership/tenant lookups into a &lt;code&gt;security definer&lt;/code&gt; function or a separate table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the order every time
&lt;/h2&gt;

&lt;p&gt;Confirm the role → confirm RLS is enabled → confirm a policy exists for that command → confirm the GRANT → read &lt;code&gt;USING&lt;/code&gt; vs &lt;code&gt;WITH CHECK&lt;/code&gt;. Ninety percent of RLS pain lives in those five checks.&lt;/p&gt;

&lt;p&gt;Want to watch each symptom fail and pass locally? I keep a runnable repro — a PGlite sandbox (no cloud project needed) plus a free, read-only 9-query audit you paste into the SQL editor — at &lt;strong&gt;github.com/cekuu35/supabase-rls-leak-demo&lt;/strong&gt;. It flags tables with RLS off, policies granted to &lt;code&gt;anon&lt;/code&gt;, and &lt;code&gt;USING (true)&lt;/code&gt; / &lt;code&gt;WITH CHECK (true)&lt;/code&gt; shapes.&lt;/p&gt;

&lt;p&gt;If you later want a deeper sweep across a whole schema — multi-tenant leaks, restrictive-policy interactions, storage buckets — I've packaged a more thorough checklist as an RLS Audit Kit ($29). Entirely optional; the free audit above unsticks most people today.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>You shipped a Supabase app with AI. Here are the 4 security holes it probably has (and the 5-minute fixes)</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:51:20 +0000</pubDate>
      <link>https://dev.to/cekuu35/you-shipped-a-supabase-app-with-ai-here-are-the-4-security-holes-it-probably-has-and-the-5-minute-31mb</link>
      <guid>https://dev.to/cekuu35/you-shipped-a-supabase-app-with-ai-here-are-the-4-security-holes-it-probably-has-and-the-5-minute-31mb</guid>
      <description>&lt;p&gt;I build security tooling for Supabase, and I keep finding the same four holes in apps that shipped fast with Lovable, Bolt, v0, Cursor, or Replit. Not careless apps — good ones. Real users, real revenue, clean UIs. The people who built them aren't sloppy; they shipped a real product in a weekend, which is genuinely hard and genuinely good. But AI codegen optimizes for &lt;em&gt;it works&lt;/em&gt;, and "it works" and "it's locked down" are two different tests. The second one rarely runs on its own.&lt;/p&gt;

&lt;p&gt;So let me run it with you — worst first, then the thing everyone panics about but shouldn't, then the two RLS holes that actually bite, then a query you can run against your own project in 60 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A &lt;code&gt;service_role&lt;/code&gt; key or DB password committed to the repo
&lt;/h2&gt;

&lt;p&gt;This is the one that can end the whole app, so it goes first. Your &lt;code&gt;service_role&lt;/code&gt; (secret) key has &lt;code&gt;BYPASSRLS&lt;/code&gt; — it &lt;strong&gt;ignores every policy you ever wrote&lt;/strong&gt; and has full read/write over your entire database. A Postgres connection string with the password in it is the same deal. When an AI tool scaffolds a backend it often writes both into &lt;code&gt;.env&lt;/code&gt;, and if &lt;code&gt;.env&lt;/code&gt; isn't ignored, one &lt;code&gt;git push&lt;/code&gt; puts your master key out there.&lt;/p&gt;

&lt;p&gt;Two traps people miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private repos aren't safe either.&lt;/strong&gt; Anything pushed while a repo was public is scraped almost immediately; flipping it to private afterward doesn't recall it. And collaborators, later public-ization, and breaches all expose a private repo too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the file doesn't fix it.&lt;/strong&gt; The secret still lives in your git history and in every clone and fork already out there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix, in order — &lt;strong&gt;rotation is what actually saves you; the history scrub is hygiene:&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="c"&gt;# 1. Rotate FIRST, in the Supabase dashboard:&lt;/span&gt;
&lt;span class="c"&gt;#    Project Settings -&amp;gt; API -&amp;gt; roll the service_role / secret key&lt;/span&gt;
&lt;span class="c"&gt;#    Project Settings -&amp;gt; Database -&amp;gt; reset the database password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat on rotation: check which key model your project is on. Newer projects use &lt;code&gt;sb_secret_&lt;/code&gt; / &lt;code&gt;sb_publishable_&lt;/code&gt; keys that can be revoked individually. Older projects use the legacy JWT-based keys, where regenerating the JWT secret rotates the anon key at the same time and drops every logged-in session — so plan for that.&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;# 2. Then purge it from history. Run this on a FRESH full clone:&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; .env &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
git remote add origin &amp;lt;your-repo-url&amp;gt;   &lt;span class="c"&gt;# filter-repo drops the remote by design&lt;/span&gt;
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
git push &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--tags&lt;/span&gt;                 &lt;span class="c"&gt;# a key can sit in a tagged commit too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;History rewriting doesn't reach existing forks, open PRs, or GitHub's caches — which is exactly why step 1 is the real mitigation. Going forward: &lt;code&gt;service_role&lt;/code&gt; lives only on a server or in Edge Function secrets, never in the browser bundle, never in the repo. Add &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.*&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; today.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Relax — your &lt;code&gt;anon&lt;/code&gt; key is &lt;em&gt;supposed&lt;/em&gt; to be public
&lt;/h2&gt;

&lt;p&gt;Half the panic I see is misdirected here. The &lt;code&gt;anon&lt;/code&gt; key (Supabase now also calls it the &lt;strong&gt;publishable&lt;/strong&gt; key) is &lt;strong&gt;public by design&lt;/strong&gt;. It ships in your client bundle on purpose. You do &lt;strong&gt;not&lt;/strong&gt; need to rotate it, and finding it in your JS is not a breach. I've watched people burn a weekend "fixing" this while the real hole sat untouched.&lt;/p&gt;

&lt;p&gt;The anon key isn't a password — it's a name tag that says "unauthenticated visitor." It's low-privilege on its own; what it can actually touch is decided by table grants &lt;strong&gt;plus&lt;/strong&gt; Row Level Security. Which is exactly why the next two holes are the ones that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. RLS off, or a policy that says &lt;code&gt;USING (true)&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;On most Supabase projects, tables in the &lt;code&gt;public&lt;/code&gt; schema get default grants for the &lt;code&gt;anon&lt;/code&gt; role and are exposed through the Data API. So if RLS is &lt;strong&gt;off&lt;/strong&gt;, the anon key reads &lt;strong&gt;every row&lt;/strong&gt;. A policy of &lt;code&gt;FOR SELECT USING (true)&lt;/code&gt; with no &lt;code&gt;TO&lt;/code&gt; clause is the same thing — it applies to everyone, &lt;code&gt;anon&lt;/code&gt; included.&lt;/p&gt;

&lt;p&gt;Turn RLS on and scope reads to the owner:&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own profile"&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;&lt;code&gt;enable row level security&lt;/code&gt; with no policy means deny-all — you then add back exactly what each role should see. The &lt;code&gt;to authenticated&lt;/code&gt; keeps anon out entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The write leak: reads and writes use different rules
&lt;/h2&gt;

&lt;p&gt;This is the subtle one, and the one AI gets wrong most. Two different expressions guard a table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/strong&gt; filters which &lt;em&gt;existing&lt;/em&gt; rows you can see or touch (SELECT, UPDATE, DELETE).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/strong&gt; validates the &lt;em&gt;new&lt;/em&gt; row's values (INSERT, and the post-update row).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;INSERT has &lt;code&gt;WITH CHECK&lt;/code&gt; only. So this — which &lt;em&gt;looks&lt;/em&gt; locked down — lets anyone signed in insert a row &lt;strong&gt;as any user&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- BAD: new row can carry any user_id&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;"add"&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;posts&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="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- GOOD: the new row must belong to the caller&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;"add 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;posts&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;Three things worth memorizing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FOR ALL USING (true)&lt;/code&gt; with no &lt;code&gt;WITH CHECK&lt;/code&gt; reuses that &lt;code&gt;true&lt;/code&gt; as the write check too — reads &lt;em&gt;and&lt;/em&gt; writes are open.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;FOR UPDATE ... USING (auth.uid() = user_id)&lt;/code&gt; with &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;WITH CHECK&lt;/code&gt; is actually &lt;strong&gt;safe&lt;/strong&gt;: Postgres reuses &lt;code&gt;USING&lt;/code&gt; as the check, so an attempt to hand a row to another user bounces.&lt;/li&gt;
&lt;li&gt;Permissive policies &lt;strong&gt;OR together&lt;/strong&gt; — one &lt;code&gt;true&lt;/code&gt; anywhere reopens that command for every role that policy applies to, and a tight policy can't claw it back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 60-second self-check
&lt;/h2&gt;

&lt;p&gt;Run this in the Supabase SQL editor, against your live project:&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;-- 1. Tables with RLS off (ignore extension/reference tables like spatial_ref_sys):&lt;/span&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;span class="c1"&gt;-- 2. anon/public policies that over-share reads or let anyone write:&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="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;and&lt;/span&gt; &lt;span class="n"&gt;permissive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PERMISSIVE'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&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="k"&gt;or&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&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="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;span class="c1"&gt;-- 3. authenticated write policies open to any logged-in user (the cross-tenant leak):&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="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;and&lt;/span&gt; &lt;span class="n"&gt;permissive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PERMISSIVE'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&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="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;cmd&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;'INSERT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ALL'&lt;/span&gt;&lt;span class="p"&gt;)&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;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="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A clean result lowers your risk but doesn't prove safety.&lt;/strong&gt; These queries only match the literal string &lt;code&gt;true&lt;/code&gt;, so functionally-open predicates like &lt;code&gt;using (1=1)&lt;/code&gt; or &lt;code&gt;using (user_id = user_id)&lt;/code&gt; slip through — eyeball anything with a suspiciously broad &lt;code&gt;qual&lt;/code&gt;. And a &lt;code&gt;for select to authenticated using (true)&lt;/code&gt; still lets every logged-in user read every tenant's rows, so give authenticated-scoped reads a second look too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest caveat:&lt;/strong&gt; RLS is often configured in the Supabase dashboard, not in committed SQL — so "there's no policy in my repo" proves nothing about production. The live query above is the only source of truth.&lt;/p&gt;




&lt;p&gt;None of this makes you a worse builder for shipping fast. Shipping fast is the whole point; you just want the "locked down" test to run once too. Turn RLS on, scope to &lt;code&gt;auth.uid()&lt;/code&gt;, keep &lt;code&gt;service_role&lt;/code&gt; off the client, and let the anon key be exactly as public as it was always meant to be — and the fast path stays the safe one.&lt;/p&gt;

&lt;p&gt;If you want to &lt;em&gt;watch&lt;/em&gt; the RLS side of this fail and then a one-line policy change close it, I put a small reproducible project 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 runs Postgres locally (PGlite, no Supabase account or credentials needed; &lt;code&gt;npm ci &amp;amp;&amp;amp; npm run test:ci&lt;/code&gt;), and the isolation tests flip from failing to passing once the right policies are in place. It also ships a free, &lt;code&gt;SELECT&lt;/code&gt;-only &lt;code&gt;audit/rls-audit.sql&lt;/code&gt; — nine catalog queries you can paste straight into the Supabase SQL editor.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you'd rather hand someone a done-for-you checklist across a whole project, a Supabase RLS Audit Kit exists — but honestly, the repo and the query above catch most of what I find.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>New Row Violates Row-Level Security Policy: The Real Fix</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:18:31 +0000</pubDate>
      <link>https://dev.to/cekuu35/the-rls-write-leak-your-read-audit-cant-see-5bkf</link>
      <guid>https://dev.to/cekuu35/the-rls-write-leak-your-read-audit-cant-see-5bkf</guid>
      <description>&lt;p&gt;I build tooling that scans Supabase projects for Row Level Security mistakes, and I keep running into the same reassuring, wrong mental model: "RLS is on, my &lt;code&gt;SELECT&lt;/code&gt; policies are scoped to &lt;code&gt;auth.uid()&lt;/code&gt;, so my data is locked." The read side often &lt;em&gt;is&lt;/em&gt; fine. The trouble is that reads and writes go through different doors, and most audits only check the one they can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reads and writes are checked by different expressions
&lt;/h2&gt;

&lt;p&gt;A Postgres RLS policy has two halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;USING&lt;/code&gt;&lt;/strong&gt; — the visibility filter. It decides which existing rows a query can &lt;em&gt;see&lt;/em&gt; (&lt;code&gt;SELECT&lt;/code&gt;, and which rows an &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; is allowed to touch).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WITH CHECK&lt;/code&gt;&lt;/strong&gt; — the write filter. It decides which &lt;em&gt;new&lt;/em&gt; row values are allowed to land (&lt;code&gt;INSERT&lt;/code&gt;, and the post-image of an &lt;code&gt;UPDATE&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch is in how they default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;INSERT&lt;/strong&gt; policies have &lt;code&gt;WITH CHECK&lt;/code&gt; only. There is no &lt;code&gt;USING&lt;/code&gt; to fall back on — an insert has no existing row to filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UPDATE&lt;/strong&gt; policies check &lt;code&gt;USING&lt;/code&gt; against the old row and &lt;code&gt;WITH CHECK&lt;/code&gt; against the new row. Omit &lt;code&gt;WITH CHECK&lt;/code&gt;, and Postgres reuses the &lt;code&gt;USING&lt;/code&gt; expression as the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FOR ALL&lt;/strong&gt; applies &lt;code&gt;USING&lt;/code&gt; to reads and to the old-row side of &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt;, and &lt;code&gt;WITH CHECK&lt;/code&gt; to the new row on &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt; — and again, omit &lt;code&gt;WITH CHECK&lt;/code&gt; and &lt;code&gt;USING&lt;/code&gt; is reused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your read-audit greps for &lt;code&gt;SELECT&lt;/code&gt; policies and confirms they say &lt;code&gt;auth.uid() = user_id&lt;/code&gt;. It never reads the write filter. That's the blind spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak #1: &lt;code&gt;WITH CHECK (true)&lt;/code&gt; on INSERT
&lt;/h2&gt;

&lt;p&gt;Here's a table where reads are perfectly locked down:&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;"read own messages"&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;messages&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the door someone left open, usually to "just get inserts working":&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;"allow inserts"&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;messages&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;with check (true)&lt;/code&gt; accepts &lt;em&gt;every&lt;/em&gt; new row. A policy with no &lt;code&gt;TO&lt;/code&gt; clause applies to &lt;code&gt;public&lt;/code&gt; — every role, anon included. So anyone holding the public anon key can insert a row with &lt;code&gt;user_id&lt;/code&gt; set to someone else and write straight into another user's inbox, feed, or audit log. Reads are locked; writes are wide open.&lt;/p&gt;

&lt;p&gt;The fix is to make the write filter say what the read filter says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own messages"&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;messages&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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;
  
  
  The subtlety: &lt;code&gt;FOR ALL USING (true)&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one trips up even careful people, so let's be exact about what is and isn't a bug.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;USING-only UPDATE is safe&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"update own todos"&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;update&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="c1"&gt;-- no with_check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;WITH CHECK&lt;/code&gt; is omitted, Postgres reuses &lt;code&gt;auth.uid() = user_id&lt;/code&gt; as the write check. Try to rewrite &lt;code&gt;user_id&lt;/code&gt; to another account and the new row fails that reused check:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;with_check IS NULL&lt;/code&gt; on an &lt;code&gt;UPDATE&lt;/code&gt; is the &lt;em&gt;safe&lt;/em&gt; shape, not the leak. Don't let an audit flag it.&lt;/p&gt;

&lt;p&gt;Now the actual leak — same reuse rule, opposite outcome:&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;"manage own todos"&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;all&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;span class="c1"&gt;-- no with_check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USING (true)&lt;/code&gt; already over-shares — it exposes every read and every delete across the table. But with no &lt;code&gt;WITH CHECK&lt;/code&gt;, that &lt;code&gt;true&lt;/code&gt; is &lt;em&gt;also&lt;/em&gt; reused as the write check, so an &lt;code&gt;INSERT&lt;/code&gt;, or an &lt;code&gt;UPDATE&lt;/code&gt; that rewrites &lt;code&gt;user_id&lt;/code&gt;, lands cleanly too. In &lt;code&gt;pg_policies&lt;/code&gt; this shows as &lt;code&gt;qual = true, with_check = null&lt;/code&gt;: the one place a null &lt;code&gt;with_check&lt;/code&gt; is genuinely dangerous, because the qual it inherits is &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissive policies OR together
&lt;/h2&gt;

&lt;p&gt;Multiple permissive policies for the same command combine with &lt;strong&gt;OR&lt;/strong&gt; — one policy that passes anywhere is the whole verdict. So this, sitting quietly beside your correct 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;"service writes"&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;all&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;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;silently re-opens every write, however good its neighbor is. And it surfaces as &lt;code&gt;cmd = 'ALL'&lt;/code&gt;, not &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt; — exactly why an audit that filters only for &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; walks right past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A write-leak detection query
&lt;/h2&gt;

&lt;p&gt;Run this against your project. It flags the anon/public write policies that let anyone write, and deliberately spares the safe USING-only update:&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="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;cmd&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;'INSERT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'UPDATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ALL'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&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="k"&gt;or&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;any&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="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&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="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ALL'&lt;/span&gt; &lt;span class="k"&gt;and&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;and&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&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;Reading it clause by clause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cmd in ('INSERT','UPDATE','ALL')&lt;/code&gt; — writes only, and &lt;code&gt;'ALL'&lt;/code&gt; is in there because a stray &lt;code&gt;FOR ALL WITH CHECK (true)&lt;/code&gt; surfaces as &lt;code&gt;cmd = 'ALL'&lt;/code&gt;, never as &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;. Leave it out and you miss the most common way a good schema gets quietly re-opened.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'anon' = any(roles) or 'public' = any(roles)&lt;/code&gt; — this scopes the scan to the anon-key path, the highest-severity, false-positive-free class: anyone with your public key. &lt;code&gt;qual&lt;/code&gt; and &lt;code&gt;with_check&lt;/code&gt; are text columns, so these are string comparisons against the literal &lt;code&gt;'true'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;with_check = 'true'&lt;/code&gt; — catches explicit permissive checks, including &lt;code&gt;FOR ALL WITH CHECK (true)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cmd = 'ALL' and qual = 'true' and with_check is null&lt;/code&gt; — catches &lt;code&gt;USING (true)&lt;/code&gt; being reused as the check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A bare &lt;code&gt;with_check IS NULL&lt;/code&gt; term is intentionally &lt;em&gt;not&lt;/em&gt; here — it would flag the safe USING-only update and bury you in false positives. One honest caveat: this query targets the anon/public class. Over-permissive writes scoped &lt;code&gt;TO authenticated&lt;/code&gt; (any logged-in user writing another tenant's rows) are a real but separate, narrower class — widen the role filter if you want to sweep for those too.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's the policy, not the key
&lt;/h2&gt;

&lt;p&gt;When people find one of these, the first instinct is often to rotate the anon key. Don't bother. The anon key is public by design — it ships in your client bundle, and it's meant to. It isn't a secret, and rotating it fixes nothing. The vulnerability is the policy that lets a caller in the anon/public scope write where it shouldn't. Fix the &lt;code&gt;WITH CHECK&lt;/code&gt;, not the key.&lt;/p&gt;




&lt;p&gt;If you'd rather feel this than take my word for it, I put together a small reproduction: &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; stands up the leaky policies, shows a cross-tenant write succeeding, then applies the fix — so you can confirm your own detection query catches it before someone else does.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;For teams who'd rather not hand-roll the checklist, I also keep a paid Supabase RLS Audit Kit — though honestly, the query above and an afternoon will get most projects there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase Storage Upload Fails With RLS (403: new row violates row-level security policy)</title>
      <dc:creator>Cenk KURTOĞLU</dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:47:52 +0000</pubDate>
      <link>https://dev.to/cekuu35/supabase-storage-upload-fails-with-rls-403-new-row-violates-row-level-security-policy-1914</link>
      <guid>https://dev.to/cekuu35/supabase-storage-upload-fails-with-rls-403-new-row-violates-row-level-security-policy-1914</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://cenkkurtoglu.com/blog/supabase-storage-rls-upload-403" rel="noopener noreferrer"&gt;cenkkurtoglu.com&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your upload throws &lt;code&gt;new row violates row-level security policy&lt;/code&gt; (HTTP 403) — but the exact same upload works with the &lt;code&gt;service_role&lt;/code&gt; key. That one detail is the whole diagnosis: files in Supabase Storage are rows in the &lt;code&gt;storage.objects&lt;/code&gt; table, RLS applies to that table, and &lt;code&gt;service_role&lt;/code&gt; bypasses RLS. So the key is not the fix — your &lt;code&gt;storage.objects&lt;/code&gt; policy simply does not match the request you are actually making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why service_role "works" and your app key does not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;service_role&lt;/code&gt; ignores every policy, so a successful upload with it proves nothing about your rules. Your browser or server upload runs as &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt;, and those go through RLS on &lt;code&gt;storage.objects&lt;/code&gt;. If no policy grants an &lt;code&gt;INSERT&lt;/code&gt; to that role for that bucket, you get the 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that actually cause it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role mismatch.&lt;/strong&gt; You are uploading with the anon key and no signed-in session, so the request is &lt;code&gt;anon&lt;/code&gt; — but your policy targets &lt;code&gt;authenticated&lt;/code&gt;. Either sign the user in first, or add an explicit &lt;code&gt;anon&lt;/code&gt; policy if public uploads are intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Public bucket" is not "public writes."&lt;/strong&gt; Making a bucket public only affects &lt;em&gt;reads&lt;/em&gt;. Uploading still needs an &lt;code&gt;INSERT&lt;/code&gt; policy on &lt;code&gt;storage.objects&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;bucket_id&lt;/code&gt; match.&lt;/strong&gt; A policy that does not scope to your bucket will not apply. Check &lt;code&gt;bucket_id = 'your-bucket'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user folder rule not met.&lt;/strong&gt; A common pattern requires the first path segment to equal the user id; if you upload to a different prefix, the &lt;code&gt;WITH CHECK&lt;/code&gt; fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A working authenticated-upload policy
&lt;/h2&gt;

&lt;p&gt;For "each signed-in user can upload into their own folder in the &lt;code&gt;avatars&lt;/code&gt; bucket":&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;"authenticated users upload to 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;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;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&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;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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload to the path &lt;code&gt;avatars/&amp;lt;user-id&amp;gt;/file.png&lt;/code&gt; and it passes. Upload to any other prefix and RLS correctly rejects it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm which role your upload runs as
&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;select&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;role&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;role&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="k"&gt;as&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;If it returns &lt;code&gt;anon&lt;/code&gt; while your policy expects &lt;code&gt;authenticated&lt;/code&gt;, the request is unauthenticated — that is the real problem, not the policy.&lt;/p&gt;




&lt;p&gt;Check every policy and role (including &lt;code&gt;storage.objects&lt;/code&gt;) in one pass with the &lt;strong&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_launch&amp;amp;utm_content=storage_403" rel="noopener noreferrer"&gt;Supabase RLS Audit Kit ($29)&lt;/a&gt;&lt;/strong&gt; — 60 checks plus a role-simulation harness that proves isolation. Free read-only demo (runs in ~2s, no Docker): &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;/p&gt;

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