<?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: Aman Gupta</title>
    <description>The latest articles on DEV Community by Aman Gupta (@amangupta678).</description>
    <link>https://dev.to/amangupta678</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%2F4141839%2Fc23eabc9-4714-48fb-8e98-ff0c1e54f6f3.jpg</url>
      <title>DEV Community: Aman Gupta</title>
      <link>https://dev.to/amangupta678</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amangupta678"/>
    <language>en</language>
    <item>
      <title>typescript-eslint suggests void. With supabase-js, that drops the request.</title>
      <dc:creator>Aman Gupta</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:18:29 +0000</pubDate>
      <link>https://dev.to/amangupta678/typescript-eslint-suggests-void-with-supabase-js-that-drops-the-request-422m</link>
      <guid>https://dev.to/amangupta678/typescript-eslint-suggests-void-with-supabase-js-that-drops-the-request-422m</guid>
      <description>&lt;p&gt;For the first 39 sends of a new feature in a production app I run, open tracking just didn't work. The sends went out fine. The opens never got recorded: 39 sends recorded zero opens. No exception, nothing in the logs, every test green. It came down to one word.&lt;/p&gt;

&lt;p&gt;The call looked roughly like this (names changed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;track_event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;void&lt;/code&gt; was a habit. typescript-eslint's &lt;code&gt;no-floating-promises&lt;/code&gt; complains about promises you don't await, and its message tells you to await it, handle it, or have it "explicitly marked as ignored with the &lt;code&gt;void&lt;/code&gt; operator". The first suggestion it offers is "Add void operator to ignore." For an eager promise, like a storage upload or an auth call, that's fine, because the request is already on its way.&lt;/p&gt;

&lt;p&gt;A supabase-js query builder isn't like that. It's a lazy thenable, and nothing gets sent until something calls &lt;code&gt;.then()&lt;/code&gt; on it. &lt;code&gt;await&lt;/code&gt; does that for you. &lt;code&gt;void&lt;/code&gt; doesn't. So &lt;code&gt;void supabase.rpc(...)&lt;/code&gt; builds the request and then throws it away.&lt;/p&gt;

&lt;p&gt;It gets worse. Since typescript-eslint 8.0.0, &lt;code&gt;no-floating-promises&lt;/code&gt; doesn't check thenables by default, so a builder you forgot on its own line isn't flagged by anything. And if you turn &lt;code&gt;checkThenables&lt;/code&gt; on to catch those, the rule flags every builder and suggests &lt;code&gt;void&lt;/code&gt; first, which is the one fix guaranteed to make sure the request never happens.&lt;/p&gt;

&lt;p&gt;So I built an ESLint plugin that knows the difference: &lt;a href="https://github.com/guptaaman678/eslint-plugin-supabase" rel="noopener noreferrer"&gt;eslint-plugin-supabase&lt;/a&gt;. It has five rules, each for a Supabase mistake that's easy to ship without noticing. Four of them don't produce an error at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The query that never gets sent
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;supabase/no-unsent-query&lt;/code&gt; flags a lazy builder that's voided or just left sitting there as a statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;supabase&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;todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// voided: never sent&lt;/span&gt;
&lt;span class="nx"&gt;supabase&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;todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// never awaited: never sent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get two suggestions, &lt;code&gt;await&lt;/code&gt; (inside an async function) and &lt;code&gt;.then()&lt;/code&gt;. There's no autofix, because both change when the request runs, and that's your call to make. &lt;code&gt;.then()&lt;/code&gt; chains, &lt;code&gt;await&lt;/code&gt;, &lt;code&gt;return&lt;/code&gt; and passing the builder somewhere else are all fine. Storage downloads are lazy too, so they're covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The subscription that never fires
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;status&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;public.messages&lt;/code&gt; isn't in the &lt;code&gt;supabase_realtime&lt;/code&gt; publication, this channel happily reaches &lt;code&gt;SUBSCRIBED&lt;/code&gt; and then never gets a single event. The only error is a system message your subscribe callback never sees. That same app had a subscription like this sitting on an unpublished table while the table took 1,926 writes. It fired zero times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;supabase/realtime-table-published&lt;/code&gt; replays your migrations and reports the subscription, along with the fix: &lt;code&gt;alter publication supabase_realtime add table public.messages;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The read that comes back empty
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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;supabase&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;orders&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="c1"&gt;// data: [], error: null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;public.orders&lt;/code&gt; has row level security on and no select policy for the role your client uses, this read gives you an empty result and no error. An update or delete in the same situation quietly changes nothing, also without an error. (Inserts and upserts fail loudly with 42501, so the rule doesn't bother with them.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;supabase/rls-readable&lt;/code&gt; reads your policies from the migrations and reports the read, plus filtered updates and deletes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The channel error nobody hears
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;onPing&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;.subscribe()&lt;/code&gt; without a status callback and &lt;code&gt;CHANNEL_ERROR&lt;/code&gt; and &lt;code&gt;TIMED_OUT&lt;/code&gt; go nowhere. &lt;code&gt;supabase/subscribe-status-handler&lt;/code&gt; asks you to pass one: &lt;code&gt;.subscribe((status, err) =&amp;gt; { ... })&lt;/code&gt;. If you already wrap &lt;code&gt;subscribe&lt;/code&gt; in a helper that checks the status, add it to &lt;code&gt;allowWrappers&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The call the 2026-10-30 change will break
&lt;/h2&gt;

&lt;p&gt;This one comes with a date, so it gets its own section further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a linter needs your schema
&lt;/h2&gt;

&lt;p&gt;Supabase's own database linter already catches something close to the empty read (the third one above): lint &lt;code&gt;0008_rls_enabled_no_policy&lt;/code&gt; flags every table with row level security on and no policy. But plenty of those tables are like that on purpose. The app only reads them through a function, and "no policy" is the design.&lt;/p&gt;

&lt;p&gt;You can't tell a deliberate table from a broken one by looking at the database alone. You can't tell from the client code alone either: &lt;code&gt;supabase.from('orders').select()&lt;/code&gt; looks fine until you know what the migrations say about &lt;code&gt;orders&lt;/code&gt;. You need both. So the schema rules replay &lt;code&gt;supabase/migrations&lt;/code&gt; into a model of the database (tables, grants, row level security, policies, publication membership) and check every client call against it. A table that's only read through a function never trips &lt;code&gt;rls-readable&lt;/code&gt;, because there's no direct client call to report.&lt;/p&gt;

&lt;p&gt;The plugin also works out which role each client acts as. A client created with a publishable or anon key is &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt;. A client created with the service role key bypasses row level security, so &lt;code&gt;rls-readable&lt;/code&gt; leaves it alone. When the plugin can't trace a client at all, it skips the call and counts the skip. When it can trace the client but can't tell what kind of key it got (&lt;code&gt;createClient(url, config.key)&lt;/code&gt;), it treats it like a publishable client by default (&lt;code&gt;unknownKey: "default"&lt;/code&gt;), because in client code that's almost always what it is. Set &lt;code&gt;unknownKey: "skip"&lt;/code&gt; if you'd rather skip those calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026-10-30: find the call sites before the change does
&lt;/h2&gt;

&lt;p&gt;Supabase has announced that from 2026-10-30, new tables, views and sequences in &lt;code&gt;public&lt;/code&gt; stop getting automatic grants for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; on every existing project. A migration that creates a table and forgets the &lt;code&gt;grant&lt;/code&gt; will apply cleanly, row level security will look right, and the first request will fail.&lt;/p&gt;

&lt;p&gt;After the change, a client call on a new table that the role has no grant for fails with 42501. &lt;a href="https://github.com/guptaaman678/supabase-grants-lint" rel="noopener noreferrer"&gt;supabase-grants-lint&lt;/a&gt; catches it at the migration. This plugin catches it at the line of client code that will break. A Realtime subscription on such a table fails silently instead: it connects and never receives any data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;supabase/table-granted&lt;/code&gt; knows which privileges each call needs (an &lt;code&gt;upsert&lt;/code&gt; needs insert, update and select; a filtered &lt;code&gt;delete&lt;/code&gt; needs delete and select) and reports a call when no role the client can act as has them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated has no insert privilege on `public.orders` in your migrations, so this call fails with
42501 (permission denied). Fix in a migration: `grant insert on public.orders to authenticated;`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right now, before the change, the replayed defaults still grant almost everything, so this rule is quiet on most projects. And the replay doesn't look at the calendar. It only sees the change once your project records it, either with the opt-in migration (the revoke SQL Supabase published) or with grants-lint's &lt;code&gt;since&lt;/code&gt; setting. Without one of those, the rule stays quiet after 2026-10-30 too. Once it's recorded, every table your next migration creates gets checked at the call site, in your editor, before the request fails in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found in 99 public repos
&lt;/h2&gt;

&lt;p&gt;I ran the recommended config over 99 public repositories that have Supabase migrations, TypeScript sources and supabase-js. I pinned the commits and linted them as they are. Out of those 99:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;35 of 99 (35.4%) had at least one finding.&lt;/li&gt;
&lt;li&gt;15 of 99 (15.2%) subscribed with &lt;code&gt;postgres_changes&lt;/code&gt; to a table their migrations never add to the publication.&lt;/li&gt;
&lt;li&gt;11 of 99 (11.1%) had a read, update or delete that row level security turns into a silent no-op, by their own migrations.&lt;/li&gt;
&lt;li&gt;8 of 99 (8.1%) had a query that's never sent, either voided or left floating.&lt;/li&gt;
&lt;li&gt;29 of 99 (29.3%) had a &lt;code&gt;.subscribe()&lt;/code&gt; with no status callback.&lt;/li&gt;
&lt;li&gt;2 of 99 (2.0%) already had a call with no privilege behind it, before the change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some honest caveats. The corpus only sees migrations, so a table published or a policy added by hand in the dashboard is invisible to it, and a finding can be right about the migrations and still not happen in that repo's production database. It's not a random sample of Supabase apps either. Calls the plugin can't resolve are skipped, so treat these numbers as lower bounds. I also did a blind hand review of 50 schema findings, drawn at random from a fresh run I hadn't looked at before. It turned up 2 false positives (4.0%), and one of those came from my own corpus harness and is fixed. I'm not naming any of the repositories, and I won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You'll need a TypeScript project with typed linting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-supabase eslint @typescript-eslint/parser &lt;span class="s2"&gt;"typescript@&amp;gt;=5.8 &amp;lt;6.1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;parser&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;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;supabase&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;eslint-plugin-supabase&lt;/span&gt;&lt;span class="dl"&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;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;ignores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/supabase/functions/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;parserOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;projectService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tsconfigRootDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dirname&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&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;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&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;Then run &lt;code&gt;npx eslint .&lt;/code&gt;. The &lt;code&gt;ignores&lt;/code&gt; line keeps Deno edge functions out of typed linting. The two code rules work on any project. The three schema rules look for &lt;code&gt;supabase/migrations&lt;/code&gt; next to your code (and read &lt;code&gt;grants-lint.config.json&lt;/code&gt; if you have one). If you created your tables in the dashboard, run &lt;code&gt;supabase db pull&lt;/code&gt; once so your migrations actually create them. The plugin doesn't open a network connection, doesn't read your &lt;code&gt;.env&lt;/code&gt; file and doesn't send telemetry.&lt;/p&gt;

&lt;p&gt;One more step for the 2026-10-30 change. Once your project is past it (or you opted in early from the dashboard), tell the replay: add the opt-in migration (the revoke SQL Supabase published), or set &lt;code&gt;since&lt;/code&gt; in &lt;code&gt;grants-lint.config.json&lt;/code&gt; to the version of your last migration before the change. Without one of those, the replay keeps the old automatic grants and &lt;code&gt;table-granted&lt;/code&gt; stays quiet on new tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;The schema rules don't parse SQL themselves. They use the replay engine from supabase-grants-lint, the migration linter I wrote for the same change, through a new &lt;code&gt;engine&lt;/code&gt; export. That way the CLI and the editor agree on what your migrations mean.&lt;/p&gt;

&lt;p&gt;Before writing this, I pointed the plugin at the history of the app where both incidents happened. At the commit before the &lt;code&gt;void&lt;/code&gt; fix, &lt;code&gt;no-unsent-query&lt;/code&gt; reports the call. At the fix, it goes quiet. The subscription was harder. That app adds tables to the publication inside a &lt;code&gt;do&lt;/code&gt; block, which a replay can't read, so the plugin skips those subscriptions instead of guessing (&lt;code&gt;reportUnresolved&lt;/code&gt; names the block and asks for plain &lt;code&gt;alter publication&lt;/code&gt; statements). With the block rewritten as plain statements, &lt;code&gt;realtime-table-published&lt;/code&gt; reports the unpublished subscription and nothing else.&lt;/p&gt;

&lt;p&gt;Every guard in the rules is mutation-tested, and every rule has a test for each case it leaves alone on purpose.&lt;/p&gt;

&lt;p&gt;It doesn't check the 1,000-row PostgREST limit. For that, use &lt;a href="https://github.com/anabecalab/rowcap" rel="noopener noreferrer"&gt;rowcap&lt;/a&gt; by anabecalab, which does it well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your project
&lt;/h2&gt;

&lt;p&gt;Run it once before 2026-10-30. If it finds something, or flags something that isn't a bug, please &lt;a href="https://github.com/guptaaman678/eslint-plugin-supabase/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;. A false positive is a bug, and I want to fix it this week.&lt;/p&gt;

&lt;p&gt;I used AI to help draft and edit this post; the plugin, the tests and the numbers are mine.&lt;/p&gt;

&lt;p&gt;Not affiliated with or endorsed by Supabase.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>eslint</category>
      <category>typescript</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Supabase stops auto-granting on Oct 30. Here is what breaks, and five traps the changelog does not mention.</title>
      <dc:creator>Aman Gupta</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:10:00 +0000</pubDate>
      <link>https://dev.to/amangupta678/supabase-stops-auto-granting-on-oct-30-here-is-what-breaks-and-five-traps-the-changelog-does-not-5h77</link>
      <guid>https://dev.to/amangupta678/supabase-stops-auto-granting-on-oct-30-here-is-what-breaks-and-five-traps-the-changelog-does-not-5h77</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmapjrqhrzajeydeljcwh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmapjrqhrzajeydeljcwh.gif" alt="supabase-grants-lint finding three missing grants, then passing"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I replayed the migration histories of 100 public Supabase projects on a database without automatic grants. In 98 of them, at least one table ended up unreachable through the Data API for some role. In 92, at least one row level security policy could never apply, because the role it names lacks the privilege the policy's command needs. Not one of the 100 histories contains the opt-in migration.&lt;/p&gt;

&lt;p&gt;That is not a statement about those projects' production databases: existing tables keep their grants. It is a statement about what their next table, their next preview branch, or their next fresh environment will look like. Here is why, and what to look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes
&lt;/h2&gt;

&lt;p&gt;Supabase has announced that from &lt;strong&gt;2026-10-30&lt;/strong&gt;, new tables, views and sequences in the &lt;code&gt;public&lt;/code&gt; schema stop getting automatic grants for &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; on every existing project (&lt;a href="https://github.com/orgs/supabase/discussions/45329" rel="noopener noreferrer"&gt;announcement&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Until now, a default privilege did the work for you. &lt;code&gt;postgres&lt;/code&gt; created a table in &lt;code&gt;public&lt;/code&gt;, and the three API roles got access to it immediately. You wrote your row level security policies and forgot that grants existed at all.&lt;/p&gt;

&lt;p&gt;After the change, you need to say 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;create&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="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_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="k"&gt;references&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;users&lt;/span&gt; &lt;span class="p"&gt;(&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="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="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;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="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;-- the new part&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forget the last two lines and nothing complains. The migration applies cleanly, the policy looks right in the dashboard, and the first request returns:&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;"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 todos"&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;Existing tables are not affected. The risk is every table you create from now on, and every environment that is built from your migration history instead of from production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five traps
&lt;/h2&gt;

&lt;p&gt;These are the parts that are easy to miss, even after reading the announcement.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Replaying your history turns the grants back on
&lt;/h3&gt;

&lt;p&gt;If your first migration came from &lt;code&gt;supabase db pull&lt;/code&gt;, it probably contains lines like these:&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="nv"&gt;"postgres"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;"anon"&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="nv"&gt;"postgres"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;"authenticated"&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="nv"&gt;"postgres"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;"service_role"&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;pg_dump&lt;/code&gt; copies your default privileges as they were when you pulled. Replay that file on a preview branch or a local &lt;code&gt;supabase db reset&lt;/code&gt;, and every table your later migrations create gets full access again. Supabase's own branching docs say so. Production no longer has those defaults, so the table that works in your preview branch fails once it ships.&lt;/p&gt;

&lt;p&gt;The local stack has a second version of the same trap: with the current CLI, new tables are exposed automatically unless you set &lt;code&gt;auto_expose_new_tables = false&lt;/code&gt; under &lt;code&gt;[api]&lt;/code&gt; in &lt;code&gt;supabase/config.toml&lt;/code&gt;. A local test run tells you nothing about grants.&lt;/p&gt;

&lt;p&gt;Of the 100 histories, 2 end with default grants still switched on by their own statements, after replaying every file. The fix is a migration that switches them off again, after the baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The revoke is narrow
&lt;/h3&gt;

&lt;p&gt;Per the SQL Supabase published, the opt-in removes &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; on tables, and &lt;code&gt;usage&lt;/code&gt; and &lt;code&gt;select&lt;/code&gt; on sequences:&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&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;tables&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="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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;usage&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;on&lt;/span&gt; &lt;span class="n"&gt;sequences&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="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old default was &lt;code&gt;grant all&lt;/code&gt;. So &lt;code&gt;truncate&lt;/code&gt;, &lt;code&gt;references&lt;/code&gt; and &lt;code&gt;trigger&lt;/code&gt; (plus &lt;code&gt;maintain&lt;/code&gt; on Postgres 17 and later) still go to &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; on every new table. Row level security does not apply to &lt;code&gt;truncate&lt;/code&gt; or &lt;code&gt;references&lt;/code&gt;. If you want clients to hold only what you granted, revoke the rest yourself:&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;truncate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;trigger&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. &lt;code&gt;service_role&lt;/code&gt; bypasses row level security, not grants
&lt;/h3&gt;

&lt;p&gt;A common assumption is that the service role key can do anything. It bypasses policies, but Postgres checks privileges before it looks at policies, and &lt;code&gt;service_role&lt;/code&gt; is not a superuser. Your edge functions, cron jobs and admin tools that use the service role key get the same &lt;code&gt;42501&lt;/code&gt; on a table nobody granted to &lt;code&gt;service_role&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;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;audit_log&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the most common finding in the corpus: 97 of the 100 histories create at least one table &lt;code&gt;service_role&lt;/code&gt; cannot read or write without the automatic grants.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Policies without grants are dead
&lt;/h3&gt;

&lt;p&gt;A policy never grants access. It only narrows access a role already has. This 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;"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;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;sender_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does nothing unless &lt;code&gt;authenticated&lt;/code&gt; holds &lt;code&gt;insert&lt;/code&gt; on &lt;code&gt;public.messages&lt;/code&gt;. Without the grant, the request fails before the policy is evaluated. The policy looks like access control, reads like access control, and never runs.&lt;/p&gt;

&lt;p&gt;In 87 of the 100 histories, a table has policies for &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; while that role holds no data privilege on it at all. Counting every mismatch, including a &lt;code&gt;for update&lt;/code&gt; policy on a table the role can only select, 92 have at least one policy that can never apply.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;serial&lt;/code&gt; columns need their sequence
&lt;/h3&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&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;user_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;total&lt;/span&gt; &lt;span class="nb"&gt;numeric&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="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="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;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;The insert still fails. A &lt;code&gt;serial&lt;/code&gt; column's default calls &lt;code&gt;nextval()&lt;/code&gt; on its own sequence, and &lt;code&gt;nextval()&lt;/code&gt; needs &lt;code&gt;usage&lt;/code&gt; (or &lt;code&gt;update&lt;/code&gt;) on that sequence. The announced revoke removes sequence &lt;code&gt;usage&lt;/code&gt; too:&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;usage&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders_id_seq&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;Identity columns (&lt;code&gt;generated always as identity&lt;/code&gt;) and &lt;code&gt;uuid&lt;/code&gt; keys do not need this: Postgres does not check sequence privileges for identity columns, and &lt;code&gt;gen_random_uuid()&lt;/code&gt; uses no sequence at all. This one is rare (1 history in the corpus), but when it happens the error names a sequence nobody remembers creating.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the numbers were measured
&lt;/h2&gt;

&lt;p&gt;The corpus is 100 public GitHub repositories with at least three files in &lt;code&gt;supabase/migrations&lt;/code&gt;, pushed within the last year, not forks, each pinned to a commit. Median history length: 30 migrations. Each history was replayed twice: once as a database without automatic grants would build it (every migration checked), and once as the project is configured today.&lt;/p&gt;

&lt;p&gt;In total the replays flagged 5,820 distinct tables and views as unreachable for at least one role, a median of 21 per project (for scale, 6,601 relations are in scope at the end of the histories). The parser read 103,326 statements and rejected 0.4% of them; each kind of rejected statement was checked against its source, and all were invalid SQL, syntax Postgres does not have, or not SQL at all. Results are published as aggregates only; no project is named.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own project
&lt;/h2&gt;

&lt;p&gt;I turned these checks into a linter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx supabase-grants-lint check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from your project root, the folder that contains &lt;code&gt;supabase/&lt;/code&gt;. If your migrations live somewhere else, point at them: &lt;code&gt;npx supabase-grants-lint check --dir db/migrations&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It reads your migrations, replays them in order into a model of who holds which privilege on which table and sequence, and prints the grant that fixes each finding:&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="n"&gt;supabase&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;20261002120000&lt;/span&gt;&lt;span class="n"&gt;_add_todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;sql&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;1&lt;/span&gt;   &lt;span class="n"&gt;error&lt;/span&gt;  &lt;span class="n"&gt;GL001&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;is&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;grant&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="p"&gt;...&lt;/span&gt;
               &lt;span class="n"&gt;fix&lt;/span&gt;   &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="n"&gt;error&lt;/span&gt;  &lt;span class="n"&gt;GL003&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;is&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;by&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;but&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt; &lt;span class="k"&gt;no&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;privilege&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
               &lt;span class="n"&gt;fix&lt;/span&gt;   &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&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;&lt;code&gt;npx supabase-grants-lint doctor&lt;/code&gt; gives a readiness report: whether an opt-in migration exists, whether replaying the history turns automatic grants back on, and which existing tables a fresh database would not expose.&lt;/p&gt;

&lt;p&gt;To keep it from happening again, run it on every pull request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx supabase-grants-lint init &lt;span class="nt"&gt;--since&lt;/span&gt; next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes a config and a GitHub Actions workflow. &lt;code&gt;--since next&lt;/code&gt; enforces every migration you add from now on and leaves your history alone, so you do not have to fix a long history before you can turn it on. Findings show up as annotations on the pull request.&lt;/p&gt;

&lt;p&gt;It reads files only. It never connects to your database and sends no telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was built
&lt;/h2&gt;

&lt;p&gt;This started as a test in a production app with about 60 tables. When I opted that project in early, I wanted proof that every table would still be reachable, so I wrote a test that replayed the whole migration history and compared the result with the grants production actually had. Every trap above is something that test caught.&lt;/p&gt;

&lt;p&gt;The open source version is a rewrite, not a copy. It parses migrations with the real Postgres parser (compiled to WebAssembly), and replays them through a small model of grants, default privileges, sequences and policies. Every rule has failing and passing fixtures, and the rules and the model are mutation-tested, so a check that stops catching its bug fails the build. Before release, it ran over the corpus above, and every false positive I found in a hand-checked sample became a fixture and a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it before Oct 30
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;npx supabase-grants-lint doctor&lt;/code&gt; on your project.&lt;/li&gt;
&lt;li&gt;Add the &lt;a href="https://github.com/marketplace/actions/supabase-grants-lint" rel="noopener noreferrer"&gt;GitHub Action&lt;/a&gt;, so the next migration is checked before it ships.&lt;/li&gt;
&lt;li&gt;If it flags something that works, or misses something that does not, please open an issue with the SQL: false-positive reports are the most useful thing you can send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is MIT licensed at &lt;a href="https://github.com/guptaaman678/supabase-grants-lint" rel="noopener noreferrer"&gt;github.com/guptaaman678/supabase-grants-lint&lt;/a&gt;. A star helps other people find it before the deadline.&lt;/p&gt;

&lt;p&gt;Not affiliated with or endorsed by Supabase.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
