<?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: Denys</title>
    <description>The latest articles on DEV Community by Denys (zaichenko).</description>
    <link>https://dev.to/zaichenko</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%2Forganization%2Fprofile_image%2F15610%2Fff2f6408-7a5d-4416-81d7-09123915f68c.jpeg</url>
      <title>DEV Community: Denys</title>
      <link>https://dev.to/zaichenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zaichenko"/>
    <language>en</language>
    <item>
      <title>Supabase anon key vs service role key: what each one opens</title>
      <dc:creator>Denys Zaichenko</dc:creator>
      <pubDate>Mon, 02 Nov 2026 16:00:49 +0000</pubDate>
      <link>https://dev.to/zaichenko/supabase-anon-key-vs-service-role-key-what-each-one-opens-42gc</link>
      <guid>https://dev.to/zaichenko/supabase-anon-key-vs-service-role-key-what-each-one-opens-42gc</guid>
      <description>&lt;p&gt;TL;DR. The anon key, now called the publishable key, is meant to be public. It maps to the &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; role, and your RLS policies decide what it sees. The service role key, now the secret key, maps to &lt;code&gt;service_role&lt;/code&gt;, which bypasses RLS entirely. On my local test table with RLS on and no policy, the public key got 0 rows and the secret key got all 3. Put the publishable key in the browser, keep the secret key on a server, and rotate it if it ever shipped.&lt;/p&gt;

&lt;p&gt;I'm Denys, a full-stack and AI engineer. A leaked secret key is the first thing I look for when I take over a Supabase app, because with it every other protection is decoration. This article explains what each key opens, with the requests I ran to check it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the Supabase anon key?
&lt;/h2&gt;

&lt;p&gt;The anon key identifies your app and not your user. Every request from a browser or a mobile app carries it, so it has to be public, and anyone can read it from the page source. A request with only this key runs as the Postgres role &lt;code&gt;anon&lt;/code&gt;. When a user signs in through Supabase Auth, the same key travels with the user's token and the request runs as &lt;code&gt;authenticated&lt;/code&gt;. Your RLS policies are written for those two roles.&lt;/p&gt;

&lt;p&gt;Supabase is renaming the keys. The &lt;a href="https://supabase.com/docs/guides/api/api-keys" rel="noopener noreferrer"&gt;API keys guide&lt;/a&gt; calls the new ones publishable (&lt;code&gt;sb_publishable_…&lt;/code&gt;) and secret (&lt;code&gt;sb_secret_…&lt;/code&gt;), and deprecates the legacy JWT-based &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; keys by the end of 2026. The roles behind them stay the same.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Legacy name&lt;/th&gt;
&lt;th&gt;Postgres role&lt;/th&gt;
&lt;th&gt;RLS applies?&lt;/th&gt;
&lt;th&gt;Where it goes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publishable &lt;code&gt;sb_publishable_…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;anon&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;anon&lt;/code&gt;, or &lt;code&gt;authenticated&lt;/code&gt; with a user token&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Browser, mobile app, anything you ship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret &lt;code&gt;sb_secret_…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;service_role&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;service_role&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No, the role has &lt;code&gt;BYPASSRLS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Server, Edge Function, cron job only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Is it safe to expose the Supabase anon key?
&lt;/h2&gt;

&lt;p&gt;Yes, if every table, view and function it can reach is protected. The key itself grants nothing special, and the grants and policies on your schema decide everything. I checked this on a local Supabase stack (CLI 2.109.1, Postgres 17.6) with one table of three fake orders.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-anon-key-vs-service-role-key.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-anon-key-vs-service-role-key.svg" alt="Which Postgres role each key becomes. And whether your RLS policies see the request. Publishable key, signed out: Publishable key → role anon → RLS policies apply, Publishable key, signed in: Publishable key + user token → role authenticated → RLS policies apply, Secret key: Secret key → role service_role → RLS skipped." width="720" height="454"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1. From the Supabase API keys guide, checked on a local stack, 23 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table state&lt;/th&gt;
&lt;th&gt;Publishable key&lt;/th&gt;
&lt;th&gt;Secret key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RLS off&lt;/td&gt;
&lt;td&gt;All 3 rows, and an insert returned &lt;code&gt;201 Created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;All 3 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RLS on, no policy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All 3 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RLS on, policy &lt;code&gt;using (true)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;All 3 rows&lt;/td&gt;
&lt;td&gt;All 3 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No grant for &lt;code&gt;anon&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;42501 permission denied&lt;/code&gt;, with a hint naming the missing &lt;code&gt;GRANT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;All 3 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the publishable key is exactly as safe as your weakest table. A table without RLS, or with a policy that allows every row, is readable by anyone who opens DevTools. That's the hole behind &lt;a href="https://mattpalmer.io/posts/2025/05/statement-on-CVE-2025-48757/" rel="noopener noreferrer"&gt;CVE-2025-48757&lt;/a&gt;, where 170 of 1,645 Lovable showcase apps served data to the public key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the service role key, and why is it dangerous?
&lt;/h2&gt;

&lt;p&gt;The secret key is the master key. It runs as &lt;code&gt;service_role&lt;/code&gt;, which has the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute, so no policy you write ever applies to it. In the last column of the table above, it read all three rows in every state. Supabase's guide lists where it belongs: servers that run their own authorization checks, Edge Functions, background jobs and admin tools. Two details from the guide matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The new secret key refuses browser requests. Supabase matches the &lt;code&gt;User-Agent&lt;/code&gt; and returns &lt;code&gt;401&lt;/code&gt;. The legacy &lt;code&gt;service_role&lt;/code&gt; JWT has no such check, so a leaked legacy key works from anywhere. My local stack, which has no such gateway check, answered a script with the secret key with all 3 rows.&lt;/li&gt;
&lt;li&gt;A secret key bypasses RLS only when the request carries no user token. With a signed-in user's token, the request runs under that user's policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An Edge Function that uses the secret key has to check who is calling and filter by owner or company itself, because nothing below it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you find the keys?
&lt;/h2&gt;

&lt;p&gt;In the Dashboard, open your project's Connect dialog for the URL and publishable key, or Settings → API Keys for every key, legacy ones included. Locally, &lt;code&gt;supabase start&lt;/code&gt; and &lt;code&gt;supabase status&lt;/code&gt; print a publishable key and a secret key. Keep them in environment variables. In Next.js, only the publishable key may carry the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, because the bundler ships anything with that prefix to the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you do if the service role key leaked?
&lt;/h2&gt;

&lt;p&gt;Treat it as compromised, because every row in every table was readable and writable by whoever found it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a new secret key, move your servers to it, then delete the leaked one. If the leak was a legacy &lt;code&gt;service_role&lt;/code&gt; key, the way out is the move to the new keys. Create a publishable and a secret key, switch the app, then disable the legacy keys in Settings → API Keys, where they otherwise stay valid.&lt;/li&gt;
&lt;li&gt;Search the built JavaScript and the Git history for &lt;code&gt;sb_secret_&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;. Escape's scan of 5,600 vibe-coded apps found &lt;a href="https://escape.tech/blog/methodology-how-we-discovered-vulnerabilities-apps-built-with-vibe-coding/" rel="noopener noreferrer"&gt;400+ exposed secrets&lt;/a&gt;, most of them in frontend bundles.&lt;/li&gt;
&lt;li&gt;Review what changed while it was out: new users, role changes, deleted rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A scanner on the database side can catch the same problem from the other direction: roles that bypass RLS, tables with RLS off, policies that allow every row and columns that look sensitive and are readable by anonymous visitors. To check what your public key reaches in practice, the steps are in &lt;a href="https://madsalt.tech/blog/vibe-coding-security-risks/" rel="noopener noreferrer"&gt;vibe coding security risks&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://madsalt.tech/blog/supabase-anon-key-vs-service-role-key/" rel="noopener noreferrer"&gt;madsalt.tech&lt;/a&gt;. Written with AI assistance, checked by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>Supabase exposed schemas: what the list opens up</title>
      <dc:creator>Denys Zaichenko</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:00:50 +0000</pubDate>
      <link>https://dev.to/zaichenko/supabase-exposed-schemas-what-the-list-opens-up-472o</link>
      <guid>https://dev.to/zaichenko/supabase-exposed-schemas-what-the-list-opens-up-472o</guid>
      <description>&lt;p&gt;TL;DR. The Exposed schemas list in Supabase decides which schemas the Data API serves to anyone holding your public key. The default is &lt;code&gt;public&lt;/code&gt; and &lt;code&gt;graphql_public&lt;/code&gt;. Every table, view and function in a schema on that list needs its own grants and RLS, so each extra schema is more surface to defend. Keep the list short and check it first.&lt;/p&gt;

&lt;p&gt;I built a free &lt;a href="https://madsalt.tech/tools/supabase-security-check/" rel="noopener noreferrer"&gt;Supabase security checker&lt;/a&gt; and the Exposed schemas list is one of the first things it reads. When someone asks me how secure their Supabase project is, this setting is where I start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "exposed" mean in Supabase?
&lt;/h2&gt;

&lt;p&gt;Supabase serves your database over REST with PostgREST. PostgREST only answers for the schemas in its &lt;code&gt;db_schema&lt;/code&gt; list. You edit it in the Dashboard under Project Settings → Data API → Exposed schemas. On the project config I looked at, the value was &lt;code&gt;public,graphql_public&lt;/code&gt;, and the Supabase &lt;a href="https://supabase.com/docs/guides/api/using-custom-schemas" rel="noopener noreferrer"&gt;custom schemas guide&lt;/a&gt; says &lt;code&gt;public&lt;/code&gt; is exposed by default. A request for a schema outside the list gets refused. &lt;a href="https://docs.postgrest.org/en/stable/references/api/schemas.html" rel="noopener noreferrer"&gt;PostgREST&lt;/a&gt; also never allows &lt;code&gt;pg_catalog&lt;/code&gt; or &lt;code&gt;information_schema&lt;/code&gt; in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the list matter?
&lt;/h2&gt;

&lt;p&gt;Everything inside an exposed schema can be reached with the public key. Tables, views and functions. Supabase's &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;RLS guide&lt;/a&gt; is blunt about it. A table in an exposed schema without RLS can be read and written by any role that has a grant on it. A schema that's not on the list can't be reached through the API at all.&lt;/p&gt;

&lt;p&gt;So an extra schema is not a neutral change. Add &lt;code&gt;storage&lt;/code&gt; or &lt;code&gt;vault&lt;/code&gt; to the list and you've put internal tables within reach of the Data API. Add your own schema and every table in it needs the same care as &lt;code&gt;public&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-exposed-schemas.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-exposed-schemas.svg" alt="What an extra exposed schema opens. One anon request, before and after trimming the list. Before: an extra schema is exposed: anon request → Data API → extra schema → no-RLS table readable. After: only public and graphql_public: anon request → Data API → default schemas only → RLS on all tables." width="720" height="350"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;From my Supabase security rules and the Supabase Data API docs, 22 October 2026.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Which extra schemas are the worst?
&lt;/h2&gt;

&lt;p&gt;An internal Supabase schema in the list (&lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;, &lt;code&gt;realtime&lt;/code&gt;, &lt;code&gt;vault&lt;/code&gt;, &lt;code&gt;extensions&lt;/code&gt; and similar) is a high severity problem when I review a project. A custom schema like &lt;code&gt;api&lt;/code&gt; is a medium one, a warning. The same list also decides where other problems count: tables with RLS off, anon write grants, always-true policies, views without &lt;code&gt;security_invoker&lt;/code&gt; and &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions that anon can call only matter in exposed schemas, because that's where the public key reaches them.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you use a custom schema safely?
&lt;/h2&gt;

&lt;p&gt;A custom schema is a legitimate pattern. Supabase's &lt;a href="https://supabase.com/docs/guides/api/securing-your-api" rel="noopener noreferrer"&gt;Securing your API&lt;/a&gt; guide describes a dedicated schema such as &lt;code&gt;api&lt;/code&gt; as a way to make the exposed surface easier to identify and audit, with internal tables kept in schemas that aren't exposed. Two steps make it work, and the &lt;a href="https://supabase.com/docs/guides/api/using-custom-schemas" rel="noopener noreferrer"&gt;custom schemas guide&lt;/a&gt; gives both. Add the schema to Exposed schemas, then grant &lt;code&gt;usage&lt;/code&gt; on it to &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;, plus privileges on its objects.&lt;/p&gt;

&lt;p&gt;The guide's example grants &lt;code&gt;ALL&lt;/code&gt; on every table. I wouldn't copy that line as it stands. The Securing your API guide says to give each role the minimum it needs, and to enable RLS on every table you expose. Grants decide if a role can touch a table at all, and policies decide which rows.&lt;/p&gt;

&lt;p&gt;There's one separate change. Supabase &lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;stops granting new &lt;code&gt;public&lt;/code&gt; tables to the Data API automatically&lt;/a&gt;. It became the default for new projects from 30 May 2026 and will be applied to existing projects on 30 October 2026, and existing tables keep their grants. That is the grants layer. It doesn't change which schemas are on the list.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you check your own project?
&lt;/h2&gt;

&lt;p&gt;Open Project Settings → Data API and read Exposed schemas. If it's more than &lt;code&gt;public&lt;/code&gt; and &lt;code&gt;graphql_public&lt;/code&gt;, ask what each extra schema is for.&lt;/p&gt;

&lt;p&gt;Then list the tables in those schemas that have RLS off, and see if &lt;code&gt;anon&lt;/code&gt; can already read 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;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="nv"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;has_table_privilege&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select'&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_can_select&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="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- your Exposed schemas&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it on a throwaway Postgres 17 with an &lt;code&gt;api&lt;/code&gt; schema holding one table with RLS and one without, plus a &lt;code&gt;public&lt;/code&gt; table without RLS. It returned only the two without:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; schema | table  | anon_can_select
--------+--------+-----------------
 api    | orders | t
 public | logs   | t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row with &lt;code&gt;anon_can_select&lt;/code&gt; true is a table anyone with your public key can read, until you enable RLS or revoke the grant. The query covers tables only. Views and functions need their own look, and my &lt;a href="https://madsalt.tech/blog/lovable-app-security-checklist/" rel="noopener noreferrer"&gt;Lovable app security checklist&lt;/a&gt; walks through them. &lt;a href="https://madsalt.tech/blog/how-to-test-supabase-rls/" rel="noopener noreferrer"&gt;How I test Supabase RLS&lt;/a&gt; shows how to prove the policies work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://madsalt.tech/notes/supabase-exposed-schemas/" rel="noopener noreferrer"&gt;madsalt.tech&lt;/a&gt;. Written with AI assistance, checked by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>Supabase 42501 permission denied is a grant problem, not RLS</title>
      <dc:creator>Denys Zaichenko</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:08:12 +0000</pubDate>
      <link>https://dev.to/zaichenko/supabase-42501-permission-denied-is-a-grant-problem-not-rls-5h8m</link>
      <guid>https://dev.to/zaichenko/supabase-42501-permission-denied-is-a-grant-problem-not-rls-5h8m</guid>
      <description>&lt;p&gt;TL;DR. &lt;code&gt;42501 permission denied for table …&lt;/code&gt; means the role has no grant on the table. Postgres stops the request before any RLS policy even runs. From 30 October 2026 Supabase stops granting new tables in &lt;code&gt;public&lt;/code&gt; automatically on existing projects, so every table you create after that answers 42501 until you grant it. The fix is three statements together - a grant, &lt;code&gt;enable row level security&lt;/code&gt; and a policy. Grant alone without RLS and the table is open to everyone.&lt;/p&gt;

&lt;p&gt;I built a free &lt;a href="https://madsalt.tech/tools/supabase-security-check/" rel="noopener noreferrer"&gt;Supabase security checker&lt;/a&gt; that reads the grants on every exposed table. Most projects have too many grants. 42501 is the other side, too few. So I reproduced it locally to see what really happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does 42501 mean?
&lt;/h2&gt;

&lt;p&gt;It's the Postgres code for insufficient privilege. Before a client touches a table Postgres checks two layers. Grants decide if the role can run the operation at all. RLS policies decide which rows it sees. A missing grant fails first and loud, as an error. A policy that matches nothing fails quietly, you just get an empty list. So if you see 42501, look at grants and leave your policies alone for now.&lt;/p&gt;

&lt;p&gt;PostgREST (the Data API behind &lt;code&gt;supabase-js&lt;/code&gt;) passes the error through with a hint. On my local stack (Supabase CLI 2.109.1, Postgres 17.6, PostgREST 14.14) a request with the publishable key to a table with no grant for &lt;code&gt;anon&lt;/code&gt; came back as HTTP 401 with 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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42501"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Grant the required privileges to the current role with: GRANT SELECT ON public.orders TO anon;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission denied for table orders"&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;h2&gt;
  
  
  Why do new tables throw 42501 after 30 October 2026?
&lt;/h2&gt;

&lt;p&gt;Until now every new table in &lt;code&gt;public&lt;/code&gt; got &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; through default privileges. So it was reachable over the Data API the second you created it, protected or not. Supabase &lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;removes those defaults&lt;/a&gt;. Optional from 28 April 2026, default for new projects from 30 May 2026, and on 30 October 2026 it hits all existing projects. Tables you already have keep their grants.&lt;/p&gt;

&lt;p&gt;I ran the same two &lt;code&gt;alter default privileges … revoke&lt;/code&gt; statements from the changelog on my local project and then created a new table &lt;code&gt;invoices&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-42501-permission-denied.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmadsalt.tech%2Fcharts%2Fen%2Fsupabase-42501-permission-denied.svg" alt="What a new table answers after 30 October 2026. Publishable key, no grant: 42501, HTTP 401. Secret key, no grant: 42501, HTTP 403. After grant select and enable RLS: 0 rows, HTTP 200." width="720" height="516"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My reproduction on a local Supabase stack with the default privileges from the Supabase changelog, 23 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request to the new table&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publishable key, no grant&lt;/td&gt;
&lt;td&gt;HTTP 401, 42501, hint &lt;code&gt;GRANT SELECT ON public.invoices TO anon;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret key, no grant&lt;/td&gt;
&lt;td&gt;HTTP 403, 42501, hint &lt;code&gt;… TO service_role;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishable key, after &lt;code&gt;grant select&lt;/code&gt; and &lt;code&gt;enable row level security&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP 200, &lt;code&gt;[]&lt;/code&gt; until a policy allows rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things surprised me. The secret key gets refused too. &lt;code&gt;service_role&lt;/code&gt; skips RLS but it still needs a grant, so an Edge Function or a server job writing to a new table breaks exactly the same way. And the revoke only takes back those four operations. The new table still came with &lt;code&gt;REFERENCES&lt;/code&gt;, &lt;code&gt;TRIGGER&lt;/code&gt; and &lt;code&gt;TRUNCATE&lt;/code&gt; for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;. No policy applies to &lt;code&gt;TRUNCATE&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you fix it without opening the table?
&lt;/h2&gt;

&lt;p&gt;Grant, RLS and policy are one change. Put them in one migration and give each role only what it needs.&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;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;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;invoices&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;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="k"&gt;on&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;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;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;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="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;"Owners read their invoices"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;revoke all&lt;/code&gt; at the top clears the &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;TRIGGER&lt;/code&gt; and &lt;code&gt;REFERENCES&lt;/code&gt; that came with the table. Please don't answer 42501 with &lt;code&gt;grant all on all tables in schema public to anon&lt;/code&gt;. That brings the old default back for every table at once, and any table without RLS becomes readable by anyone who has your public key.&lt;/p&gt;

&lt;p&gt;If an AI tool creates your tables, tell it to write grants, RLS and policies in the same migration. The hint in the error is written so an agent can fix the grant by itself, but it only names the grant. It says nothing about the RLS that should come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your own project
&lt;/h2&gt;

&lt;p&gt;Look for three things in the grants layer. Default privileges that still grant new tables to the API automatically. Write grants for &lt;code&gt;anon&lt;/code&gt; that no policy uses. And &lt;code&gt;TRUNCATE&lt;/code&gt;, &lt;code&gt;TRIGGER&lt;/code&gt; and &lt;code&gt;REFERENCES&lt;/code&gt; on tables for &lt;code&gt;authenticated&lt;/code&gt;, like the ones above. Why RLS still matters after the grant is in &lt;a href="https://madsalt.tech/blog/how-to-test-supabase-rls/" rel="noopener noreferrer"&gt;how to test Supabase RLS&lt;/a&gt;, and how grants and policies look across a whole app is in my &lt;a href="https://madsalt.tech/blog/lovable-app-security-checklist/" rel="noopener noreferrer"&gt;Lovable app security checklist&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://madsalt.tech/notes/supabase-42501-permission-denied/" rel="noopener noreferrer"&gt;madsalt.tech&lt;/a&gt;. Written with AI assistance, checked by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
