<?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: VibeSheriff</title>
    <description>The latest articles on DEV Community by VibeSheriff (@vibesheriff).</description>
    <link>https://dev.to/vibesheriff</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%2F4193869%2Fb51d207e-7ce2-4704-afd9-2efa74c58b1a.png</url>
      <title>DEV Community: VibeSheriff</title>
      <link>https://dev.to/vibesheriff</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vibesheriff"/>
    <language>en</language>
    <item>
      <title>The anon key is public, the service_role key isn't: an RLS checklist for AI-built Supabase apps</title>
      <dc:creator>VibeSheriff</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:36:18 +0000</pubDate>
      <link>https://dev.to/vibesheriff/the-anon-key-is-public-the-servicerole-key-isnt-an-rls-checklist-for-ai-built-supabase-apps-34f6</link>
      <guid>https://dev.to/vibesheriff/the-anon-key-is-public-the-servicerole-key-isnt-an-rls-checklist-for-ai-built-supabase-apps-34f6</guid>
      <description>&lt;p&gt;AI builders like Lovable, Bolt, Cursor and v0 can wire a Supabase backend into your app in minutes. That's great, but the same few misunderstandings keep showing up in the apps they generate. None of them are exotic. They're the kind of thing that's easy to miss when the app "just works".&lt;/p&gt;

&lt;p&gt;Here's the checklist I run through on a Supabase app before calling it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The anon key being public is fine. RLS is what protects you.
&lt;/h2&gt;

&lt;p&gt;The publishable key (&lt;code&gt;sb_publishable_...&lt;/code&gt;, or the legacy &lt;code&gt;anon&lt;/code&gt; key) is designed to live in your frontend. Anyone who opens dev tools can see it, and that's expected. What actually protects your data is Row Level Security (RLS).&lt;/p&gt;

&lt;p&gt;Find tables in the &lt;code&gt;public&lt;/code&gt; schema that don't have RLS turned 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;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&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;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything with &lt;code&gt;rowsecurity = false&lt;/code&gt; can be read, changed and deleted by anyone holding the public key, as long as the table's grants allow it. On older projects they do by default: every new table in &lt;code&gt;public&lt;/code&gt; is granted to &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; automatically. Newer projects (and new tables in existing projects from 30 Oct 2026) no longer get those automatic grants, but when an AI tool hits a &lt;code&gt;permission denied&lt;/code&gt; error, its fix is often &lt;code&gt;grant all on ... to anon&lt;/code&gt;, which puts you right back. See who can reach what:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The Security Advisor in the Supabase dashboard flags tables with RLS off too.&lt;/p&gt;

&lt;p&gt;Tables you create in the Table Editor get RLS switched on by default, but tables created with raw SQL (which is what AI tools often generate) don't. Turn it on 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;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;todos&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;Careful on a live app:&lt;/strong&gt; RLS with no policies blocks everyone using the public key, including your logged-in users. Reads come back empty and writes fail until you add policies. Add the policies from section 2 at the same time (or test on a branch or staging project first). Server code using the secret key isn't affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "RLS enabled" isn't the same as "RLS correct"
&lt;/h2&gt;

&lt;p&gt;Once RLS is on, read every 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;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;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;Red flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qual&lt;/code&gt; showing &lt;code&gt;true&lt;/code&gt; on a &lt;code&gt;SELECT&lt;/code&gt; policy (that's &lt;code&gt;using (true)&lt;/code&gt;) means everyone can read every row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;roles&lt;/code&gt; showing &lt;code&gt;{anon}&lt;/code&gt; or &lt;code&gt;{public}&lt;/code&gt; on a table that holds user data. &lt;code&gt;{public}&lt;/code&gt; is what you get when the policy has no &lt;code&gt;to ...&lt;/code&gt; clause, and it includes logged-out visitors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;with_check&lt;/code&gt; showing &lt;code&gt;true&lt;/code&gt; on an &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; policy, or an &lt;code&gt;INSERT&lt;/code&gt; policy that never compares &lt;code&gt;user_id&lt;/code&gt; to &lt;code&gt;auth.uid()&lt;/code&gt;, so a user can write rows in someone else's name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical "users only see their own rows" setup looks like this (it assumes a &lt;code&gt;user_id uuid&lt;/code&gt; column holding the owner's auth user id):&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 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;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="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 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;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;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Test it like an outsider (on your own project only)
&lt;/h2&gt;

&lt;p&gt;The dashboard runs queries as a privileged role, so it won't show you what a stranger sees. Ask the REST API directly with only the 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/&amp;lt;table&amp;gt;?select=*&amp;amp;limit=1"&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;publishable or anon key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a read-only &lt;code&gt;GET&lt;/code&gt;, so it can't change anything. If rows come back that shouldn't be public, your policies need work. For a private table, an empty array &lt;code&gt;[]&lt;/code&gt; (or a &lt;code&gt;permission denied&lt;/code&gt; error) is what you want.&lt;/p&gt;

&lt;p&gt;One catch: when RLS blocks a read you don't get a 401. You get &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;[]&lt;/code&gt;, which is exactly what an empty table returns too. So test against a table that actually has data in it.&lt;/p&gt;

&lt;p&gt;Also worth knowing: table names aren't secret. Supabase stopped serving the API's OpenAPI description to the public key in 2026, but your names are still sitting in your JavaScript (&lt;code&gt;.from('todos')&lt;/code&gt;), so don't rely on nobody guessing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The service_role key must never reach the browser
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;service_role&lt;/code&gt; key (or the newer &lt;code&gt;sb_secret_...&lt;/code&gt; key) bypasses RLS completely. If it ends up in your frontend bundle, none of the policies above matter.&lt;/p&gt;

&lt;p&gt;You can't just grep your build for the word "service_role", because the legacy key is a JWT and its payload is base64-encoded. Decode the JWTs in your build output instead. This prints only the decoded middle part of each token, never the signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
&lt;span class="c"&gt;# Vite: dist/   Next.js: .next/static/   Create React App: build/&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s1"&gt;'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+'&lt;/span&gt; dist/ | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; |
  node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean)
    .forEach(t =&amp;gt; console.log(Buffer.from(t.split(".")[1], "base64url").toString()))'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(It uses Node rather than &lt;code&gt;base64 -d&lt;/code&gt; because JWTs drop the base64 padding, and some &lt;code&gt;base64&lt;/code&gt; versions choke on that or cut off the end.)&lt;/p&gt;

&lt;p&gt;Every payload should say &lt;code&gt;"role":"anon"&lt;/code&gt;. If you see &lt;code&gt;"role":"service_role"&lt;/code&gt;, treat the key as leaked. Remove it from the frontend first, then create a new secret key under Settings &amp;gt; API Keys, switch your server code to it, and deactivate the legacy keys once nothing uses them. Note that deactivating the legacy keys switches off the &lt;code&gt;anon&lt;/code&gt; key too, so move your frontend to the publishable key before you flip that switch. Whatever needed the service_role key in the browser belongs in an Edge Function or your own server.&lt;/p&gt;

&lt;p&gt;The usual cause is an env var with a frontend prefix. Anything named &lt;code&gt;VITE_...&lt;/code&gt;, &lt;code&gt;NEXT_PUBLIC_...&lt;/code&gt; or &lt;code&gt;EXPO_PUBLIC_...&lt;/code&gt; gets compiled into the client. If you're on the new key format, &lt;code&gt;grep -rl 'sb_secret_' dist/&lt;/code&gt; lists any build files that contain a secret key, without printing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Views and storage have their own rules
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Views&lt;/strong&gt; run with the permissions of the role that created them (usually &lt;code&gt;postgres&lt;/code&gt;), so by default they ignore RLS on the tables underneath. On Postgres 15 and later, create them with &lt;code&gt;with (security_invoker = true)&lt;/code&gt; so the caller's policies apply, or fix an existing one with &lt;code&gt;alter view public.my_view set (security_invoker = true);&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage buckets&lt;/strong&gt; marked public let anyone with a file's URL download it. Uploads and deletes still go through policies, but downloads don't. Keep user uploads in private buckets and add policies on &lt;code&gt;storage.objects&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Check the rest of the deployment too
&lt;/h2&gt;

&lt;p&gt;While you're at it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/.env&lt;/code&gt; and &lt;code&gt;/.git/config&lt;/code&gt; shouldn't be publicly reachable. Watch out for SPA hosts that return &lt;code&gt;index.html&lt;/code&gt; with a &lt;code&gt;200&lt;/code&gt; for every path; look at the response body, not just the status code.&lt;/li&gt;
&lt;li&gt;Look for other secrets in the bundle: &lt;code&gt;sk_live_&lt;/code&gt;, &lt;code&gt;sk_test_&lt;/code&gt; and &lt;code&gt;rk_live_&lt;/code&gt; (Stripe), &lt;code&gt;sk-proj-&lt;/code&gt; and &lt;code&gt;sk-svcacct-&lt;/code&gt; (OpenAI), &lt;code&gt;sk-ant-&lt;/code&gt; (Anthropic), &lt;code&gt;AKIA&lt;/code&gt; (AWS). Older OpenAI keys start with plain &lt;code&gt;sk-&lt;/code&gt;, but that also matches plenty of harmless strings, so check each hit by eye. Stripe &lt;code&gt;pk_&lt;/code&gt; keys are public by design.&lt;/li&gt;
&lt;li&gt;Wildcard CORS (&lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;) on your own APIs that return user data deserves a second look.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Generated policies need a human read
&lt;/h2&gt;

&lt;p&gt;AI tools will happily write a policy that makes a permissions error go away. If a policy was added to fix an error, it's the first one to re-read. &lt;code&gt;using (true)&lt;/code&gt; is a very effective way to make an error disappear.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built &lt;a href="https://vibesheriff.com" rel="noopener noreferrer"&gt;VibeSheriff&lt;/a&gt;, which runs step 3 for you against a live site you've verified you own. It finds your Supabase URL and public key in the page and its JavaScript, picks up the table names your app's JavaScript queries, and tries to read one row from each with only that key. A table is flagged only if a row actually comes back, and only table names are reported. It's read-only. The free scan shows your score and top 3 issues. Everything above works without it, though, and I'm happy to answer RLS questions in the comments either way.&lt;/em&gt; 🤠&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
