<?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: Dmitry Maranik</title>
    <description>The latest articles on DEV Community by Dmitry Maranik (@dmitrymaranik).</description>
    <link>https://dev.to/dmitrymaranik</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%2F3952896%2F059ae87b-d06b-4f74-896d-bb82d33aef3f.png</url>
      <title>DEV Community: Dmitry Maranik</title>
      <link>https://dev.to/dmitrymaranik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dmitrymaranik"/>
    <language>en</language>
    <item>
      <title>I source-reviewed 200+ self-hosted AI tools for tenant isolation. 78 leaked across tenants.</title>
      <dc:creator>Dmitry Maranik</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:41:00 +0000</pubDate>
      <link>https://dev.to/dmitrymaranik/i-source-reviewed-200-self-hosted-ai-tools-for-tenant-isolation-78-leaked-across-tenants-4ff4</link>
      <guid>https://dev.to/dmitrymaranik/i-source-reviewed-200-self-hosted-ai-tools-for-tenant-isolation-78-leaked-across-tenants-4ff4</guid>
      <description>&lt;p&gt;&lt;em&gt;The same isolation flaw, the un-retrofitted read sibling, in 78 of 200+ multi-tenant AI and SaaS products. The pattern, the fixes that shipped, and how to check your own.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every team building on top of LLMs eventually ships the same feature: workspaces. Tenants, teams, projects, organizations, whatever you call them, the promise is identical. Your knowledge base, your chat history, your documents, walled off from everyone else on the same instance.&lt;/p&gt;

&lt;p&gt;That wall is the one thing a multi-tenant product cannot get wrong. So over the past few months I went looking, systematically, for where it cracks.&lt;/p&gt;

&lt;p&gt;I source-reviewed 200+ multi-tenant AI and SaaS products for one specific class of bug, and confirmed cross-tenant data exposure in 78 of them: one tenant able to read (and in some cases modify or delete) another tenant's data. That comes to 84 findings across 78 products, with 31 now filed as GitHub Security Advisories. Almost all of them are the same mistake, and it lives in the read endpoints.&lt;/p&gt;

&lt;p&gt;This post explains that mistake, names the ones that are already fixed, and points to the live list of the rest. Everything still unnamed stays that way until its fix ships. I'm not going to hand attackers a live target before users are protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;p&gt;Two tiers, because 200+ deep labs isn't something one person does, but 200+ source reviews is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sweep (200+ products).&lt;/strong&gt; For each candidate I read the code for one pattern: an authorization check that's enforced on the write path but skipped on a neighboring read. Most products either don't have the bug, aren't actually multi-tenant in their open-source edition, or have already retrofitted every sibling. Those are the majority, and they're the denominator that makes the 78 mean something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The confirmation (the findings).&lt;/strong&gt; When the source review flagged a real gap, I confirmed it for real:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stood up a clean, self-hosted instance (Docker, on my own hardware).&lt;/li&gt;
&lt;li&gt;Created two tenants, A and B, with synthetic accounts and synthetic canary data. No real users, no production systems, ever.&lt;/li&gt;
&lt;li&gt;Reproduced the cross-tenant read, modify, or delete with &lt;a href="https://sectum.ai" rel="noopener noreferrer"&gt;Sectum AI&lt;/a&gt;, an open-source multi-tenant isolation verifier, and confirmed it against database ground truth.&lt;/li&gt;
&lt;li&gt;Captured a signed (RFC-3161-timestamped) evidence pack and reported it privately to the maintainer under coordinated disclosure (90-day hold), usually with the fix attached.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step is why the scoreboard below is mostly blank. Most of the 84 findings are still under coordinated disclosure. The maintainers have been told and are shipping fixes, so I name only the ones that are already public and aggregate the rest. The full current list, and each advisory as it publishes, lives at &lt;a href="https://sectum.ai/research" rel="noopener noreferrer"&gt;sectum.ai/research&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: writes are guarded, reads are not
&lt;/h2&gt;

&lt;p&gt;What made the sweep almost mechanical is that the same shape kept repeating. The write path was correctly scoped to the tenant, and the read path right next to it was not.&lt;/p&gt;

&lt;p&gt;A developer adds "delete this item" and remembers to check ownership: does this item belong to the caller's tenant? Good. Then someone adds "view this item" or "list this item's embeddings," and the check never gets copied over. The list/get/search endpoint fetches by a raw ID with no tenant filter. The IDs are often sequential integers or otherwise guessable, so "view item 5" happily returns another tenant's item 5.&lt;/p&gt;

&lt;p&gt;I started calling these un-retrofitted read siblings. Once you've seen one, you grep for the rest: find the endpoint that checks ownership, then look at the neighbors that don't. Across 78 products it was the same shape almost every time. This isn't 84 unrelated bugs. It's one bug, 84 times.&lt;/p&gt;

&lt;p&gt;There's a second, nastier variant. The check runs, but on the wrong thing. The handler authorizes the path you supplied rather than the object you asked for. You pass your own workspace's ID, which you're allowed to touch, along with a victim's object ID, and the permission check and the object lookup disagree about whose data this is. The two most damaging classes I confirmed were an embedding-vector read (raw RAG vectors, which can partially reconstruct the source text) and a connector re-index that exfiltrates another tenant's content using the victim's own stored OAuth token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ones already fixed
&lt;/h2&gt;

&lt;p&gt;Five are public because the maintainers shipped a fix. The teams that moved fastest deserve the credit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Issue (class)&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SurfSense&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-tenant connector re-index to exfiltrate another tenant's GitHub/Notion content using their stored credentials&lt;/td&gt;
&lt;td&gt;✅ Fixed: &lt;a href="https://github.com/MODSetter/SurfSense/pull/1503" rel="noopener noreferrer"&gt;PR #1503 merged&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AnythingLLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embed widget exposed to anonymous origins by default&lt;/td&gt;
&lt;td&gt;✅ Fixed: &lt;a href="https://github.com/Mintplex-Labs/anything-llm/pull/5759" rel="noopener noreferrer"&gt;CVE-2025-63390, PR #5759 merged&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Baserow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-workspace disclosure of a field's values by serial ID&lt;/td&gt;
&lt;td&gt;✅ Fixed and deployed: &lt;a href="https://github.com/baserow/baserow/pull/5613" rel="noopener noreferrer"&gt;PR #5613&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;aideepin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-user read of another user's RAG chunk text, embedding vectors, and document text by ID&lt;/td&gt;
&lt;td&gt;✅ Fixed: &lt;a href="https://github.com/moyangzhan/langchain4j-aideepin/pull/105" rel="noopener noreferrer"&gt;PR #105 merged&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flagsmith&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-project read of another project's multivariate-flag values; the fix swept four sibling nested viewsets&lt;/td&gt;
&lt;td&gt;✅ Fixed: &lt;a href="https://github.com/Flagsmith/flagsmith/pull/7945" rel="noopener noreferrer"&gt;PR #7945 merged&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;79 more&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;RAG and document leaks, credential-store IDORs, cross-workspace read and tamper, up to Critical severity&lt;/td&gt;
&lt;td&gt;⏳ Under coordinated disclosure, &lt;a href="https://sectum.ai/research" rel="noopener noreferrer"&gt;named at sectum.ai/research as fixes ship&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Who passed
&lt;/h2&gt;

&lt;p&gt;Plenty of products held. A few are worth naming, because credit is due. Several had clearly audited the whole bug class after a single report, which is the right way to respond.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation held on current releases:&lt;/strong&gt; Open WebUI, Langfuse, LibreChat, Outline, PraisonAI, Onyx, LangWatch, Khoj. A few had cross-tenant CVEs in older versions; on current releases they're clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not multi-tenant in the open-source edition&lt;/strong&gt; (no cross-tenant surface to leak): Flowise, Mem0, MaxKB, vLLM. Their workspace isolation is an enterprise feature or an explicitly documented shared-by-design behavior. Worth knowing if you self-host them assuming a wall you don't actually have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means if you're building on AI
&lt;/h2&gt;

&lt;p&gt;Three takeaways, in order of how often they bit.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authorize reads like you authorize writes.&lt;/strong&gt; The list/get/search endpoint is not "lower risk" than delete. It's the exfiltration endpoint. When you add an ownership check, grep for every sibling that touches the same object and give them the same check. Better still, enforce tenant scoping at a layer the individual handler can't forget: a query interceptor, a row-level policy, a middleware. Then isolation is the default instead of something each endpoint opts into.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat embeddings and cache keys as tenant data.&lt;/strong&gt; Some of these leaks weren't documents at all. They were RAG vectors and response caches whose keys forgot to include the tenant. If A and B can land in the same cache bucket or read each other's vectors, that is a data leak.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If you self-host, verify the isolation you're assuming.&lt;/strong&gt; Several popular tools are single-tenant, or shared by design, in their open-source edition. That's a fine design. But if you're putting multiple customers on one instance expecting a wall, check that the wall exists.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  On disclosure
&lt;/h2&gt;

&lt;p&gt;Everything here was tested on isolated, self-hosted instances with synthetic accounts and synthetic data. Nothing touched a third-party or production system. Every finding went to the maintainer privately first, with a 90-day hold and an offer to write the fix. The handful named here are public because they're fixed or have an open fix PR. The rest stay unnamed until the people who maintain them have shipped. The full current list is kept up to date at &lt;a href="https://sectum.ai/research" rel="noopener noreferrer"&gt;sectum.ai/research&lt;/a&gt;, where each advisory appears as it publishes.&lt;/p&gt;

&lt;p&gt;If you maintain one of the products still under embargo and want to coordinate timing or a CVE, reach me through &lt;a href="https://sectum.ai/research" rel="noopener noreferrer"&gt;sectum.ai/research&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;The checks I ran are open source. &lt;a href="https://sectum.ai" rel="noopener noreferrer"&gt;Sectum AI&lt;/a&gt; stands up two tenants, runs the cross-tenant battery (IDOR/BOLA, RAG entity-bleed, cache contamination, erasure), and emits a signed evidence pack you can hand to an auditor, without having to trust the vendor's word that isolation works. If you're shipping multi-tenant AI, point it at a staging instance before someone else points something less friendly at production.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Found and verified with &lt;a href="https://sectum.ai" rel="noopener noreferrer"&gt;Sectum AI&lt;/a&gt;. The full current disclosure list, updated as each advisory publishes, is at &lt;a href="https://sectum.ai/research" rel="noopener noreferrer"&gt;sectum.ai/research&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Lint-Ran 4 Public Supabase Apps. They All Leak Rows.</title>
      <dc:creator>Dmitry Maranik</dc:creator>
      <pubDate>Tue, 26 May 2026 17:44:07 +0000</pubDate>
      <link>https://dev.to/dmitrymaranik/i-lint-ran-4-public-supabase-apps-they-all-leak-rows-4f39</link>
      <guid>https://dev.to/dmitrymaranik/i-lint-ran-4-public-supabase-apps-they-all-leak-rows-4f39</guid>
      <description>&lt;p&gt;I ran &lt;a href="https://github.com/pgrls/pgrls" rel="noopener noreferrer"&gt;&lt;code&gt;pgrls&lt;/code&gt;&lt;/a&gt; — an open-source&lt;br&gt;
Postgres Row-Level Security linter I maintain — against four public&lt;br&gt;
Supabase-flavored repos. Three of them are first-party examples or&lt;br&gt;
official partners:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Who maintains it&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/supabase/storage-api" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/storage-api&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/nextjs-subscription-payments" rel="noopener noreferrer"&gt;&lt;code&gt;vercel/nextjs-subscription-payments&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Vercel × Stripe × Supabase&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/supabase/supabase/tree/master/examples/slack-clone" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/supabase/examples/slack-clone&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/supabase/supabase/tree/master/examples/todo-list" rel="noopener noreferrer"&gt;&lt;code&gt;supabase/supabase/examples/todo-list&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Zero false positives across the 96 findings.&lt;/strong&gt; Every flagged&lt;br&gt;
policy is either a real bug or a known-acceptable trade-off worth&lt;br&gt;
surfacing for review.&lt;/p&gt;
&lt;h2&gt;
  
  
  What "correct" looks like first
&lt;/h2&gt;

&lt;p&gt;Supabase's own &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;RLS guide&lt;/a&gt;&lt;br&gt;
is direct about it. Under&lt;br&gt;
&lt;strong&gt;"Specify roles in your policies"&lt;/strong&gt; (in the RLS-performance&lt;br&gt;
recommendations section) the docs say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Always use the Role of inside your policies, specified by the&lt;br&gt;
&lt;code&gt;TO&lt;/code&gt; operator.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And demonstrate the pattern for anonymous access as a &lt;em&gt;separate,&lt;br&gt;
explicit&lt;/em&gt; policy, not a default-on side effect:&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;"Public profiles are visible to everyone."&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;                          &lt;span class="c1"&gt;-- explicit anon allow&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The translation in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Logged-in users read messages:&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;"auth read"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;      &lt;span class="c1"&gt;-- explicit role binding&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Anonymous reads, only if you actually want them:&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;"anon read"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;               &lt;span class="c1"&gt;-- separate, intentional&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;is_public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard isn't load-bearing because it's elegant — it's&lt;br&gt;
load-bearing because the alternative ships rows to anonymous&lt;br&gt;
clients by accident. A policy without an explicit &lt;code&gt;TO&lt;/code&gt; clause&lt;br&gt;
defaults to &lt;code&gt;TO public&lt;/code&gt;, which means &lt;em&gt;every&lt;/em&gt; role on the database,&lt;br&gt;
including &lt;code&gt;anon&lt;/code&gt;. The 96 findings are what falls out when four&lt;br&gt;
sample apps are measured against this single principle and the&lt;br&gt;
small handful of related ones (FORCE the owner role, scope by&lt;br&gt;
&lt;code&gt;auth.uid()&lt;/code&gt; not &lt;code&gt;current_user&lt;/code&gt;, wrap auth calls for the planner).&lt;/p&gt;
&lt;h2&gt;
  
  
  The four rules that fire on every repo
&lt;/h2&gt;

&lt;p&gt;Across all four codebases, the same four rules appear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;Why it's everywhere&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEC002&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RLS enabled but &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; missing&lt;/td&gt;
&lt;td&gt;The owner role (typically the migration role) bypasses RLS until you opt into &lt;code&gt;FORCE&lt;/code&gt;. Default-off in Postgres.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEC003&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Permissive policy with no &lt;code&gt;TO &amp;lt;role&amp;gt;&lt;/code&gt; clause&lt;/td&gt;
&lt;td&gt;Defaults to &lt;code&gt;TO public&lt;/code&gt;, which reaches anonymous (&lt;code&gt;anon&lt;/code&gt;) connections by accident.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEC007&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All policies on a table are permissive&lt;/td&gt;
&lt;td&gt;A single typo in &lt;code&gt;RESTRICTIVE&lt;/code&gt; (or forgetting it entirely) collapses the OR-chain to "anyone matching ANY policy passes."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEC016&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;service_role&lt;/code&gt; has &lt;code&gt;BYPASSRLS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;True by design on Supabase, but surfaced so an operator can audit the blast radius before granting it to anything else.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These four are the default-Supabase posture findings. Nothing&lt;br&gt;
exotic. Every Supabase project has them on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the deviation looks like in practice
&lt;/h2&gt;

&lt;p&gt;The densest single finding is &lt;strong&gt;SEC003 in slack-clone — 13&lt;br&gt;
instances&lt;/strong&gt; of the same shape. From&lt;br&gt;
&lt;a href="https://github.com/supabase/supabase/blob/master/examples/slack-clone/nextjs-slack-clone/full-schema.sql#L87" rel="noopener noreferrer"&gt;&lt;code&gt;examples/slack-clone/nextjs-slack-clone/full-schema.sql#L87&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Allow logged-in read access"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;TO &amp;lt;role&amp;gt;&lt;/code&gt; clause, so the policy defaults to &lt;code&gt;TO public&lt;/code&gt;. The&lt;br&gt;
predicate (&lt;code&gt;auth.role() = 'authenticated'&lt;/code&gt;) catches it at runtime&lt;br&gt;
&lt;em&gt;today&lt;/em&gt;, but the policy still runs for &lt;code&gt;anon&lt;/code&gt; connections — any&lt;br&gt;
later predicate change (a misplaced &lt;code&gt;OR&lt;/code&gt;, a NULL-tolerant&lt;br&gt;
&lt;code&gt;IS DISTINCT FROM&lt;/code&gt;, the SEC004 &lt;code&gt;auth_func() IS NULL OR …&lt;/code&gt; shape)&lt;br&gt;
immediately leaks every message to unauthenticated callers.&lt;/p&gt;

&lt;p&gt;The fix is moving the role gate from the predicate into the &lt;code&gt;TO&lt;/code&gt;&lt;br&gt;
clause, where it belongs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"Allow logged-in read access"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;         &lt;span class="c1"&gt;-- ← gate here, not in USING&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;13 policies in slack-clone follow the broken shape. 5 do in&lt;br&gt;
nextjs-subscription-payments. 4 in todo-list. Same fix line each&lt;br&gt;
time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The performance footgun: PERF001 in 19 of the 4 repos
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;          &lt;span class="c1"&gt;-- re-evaluates per row&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;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="c1"&gt;-- evaluated once per query&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase's own RLS docs recommend the&lt;br&gt;
&lt;code&gt;(SELECT auth.uid())&lt;/code&gt; wrap&lt;/a&gt;&lt;br&gt;
for performance reasons — a row-by-row &lt;code&gt;auth.uid()&lt;/code&gt; call is&lt;br&gt;
quadratic-ish on a large SELECT, while the wrapped form lets the&lt;br&gt;
planner evaluate once. &lt;strong&gt;19 policies across the four repos miss&lt;br&gt;
this wrap.&lt;/strong&gt; pgrls flags it as &lt;code&gt;PERF001&lt;/code&gt; and auto-fixes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What 0 false positives means
&lt;/h2&gt;

&lt;p&gt;I don't think most lint tools earn the "0 false positives" claim&lt;br&gt;
honestly. Two things make pgrls's count credible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It analyzes the live database catalog, not source files.&lt;/strong&gt; The
policy that fires is the policy Postgres will actually enforce —
not a guess at what the migration text resolves to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each rule is severity-rated and configurable.&lt;/strong&gt; "False
positive" in the strictest sense means "the rule flags something
that's not a bug." Across these 96 findings, every flagged
policy either &lt;em&gt;is&lt;/em&gt; a bug or has a known-acceptable trade-off
(e.g., &lt;code&gt;service_role&lt;/code&gt;'s &lt;code&gt;BYPASSRLS&lt;/code&gt; on Supabase is intentional
— but &lt;code&gt;pgrls.toml&lt;/code&gt;'s &lt;code&gt;allowlist&lt;/code&gt; lets the operator mark it as
acknowledged rather than silently ignored).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a finding doesn't apply to your project, it's an allowlist&lt;br&gt;
entry, not a bug in the linter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[lint.rules.SEC016]&lt;/span&gt;
&lt;span class="py"&gt;allowlist&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"service_role"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Try it against your own schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pgrls
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'postgres://…'&lt;/span&gt;
pgrls lint &lt;span class="nt"&gt;--schemas&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Supabase, the dev workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase start
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;supabase status &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;DB_URL | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
pgrls lint &lt;span class="nt"&gt;--schemas&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or as a one-step GitHub Action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pgrls/pgrls-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;database-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SUPABASE_DB_URL }}&lt;/span&gt;
    &lt;span class="na"&gt;schemas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you share a (sanitized) policy that pgrls misses or&lt;br&gt;
misclassifies, the catalog grows from real schemas, not synthetic&lt;br&gt;
test fixtures. Drop a thread at&lt;br&gt;
&lt;a href="https://github.com/pgrls/pgrls/discussions" rel="noopener noreferrer"&gt;github.com/pgrls/pgrls/discussions&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;47 rules total, 12 with mechanical auto-fixes. MIT,&lt;br&gt;
framework-agnostic, runs against any Postgres 15+. Source:&lt;br&gt;
&lt;a href="https://github.com/pgrls/pgrls" rel="noopener noreferrer"&gt;github.com/pgrls/pgrls&lt;/a&gt;. Docs:&lt;br&gt;
&lt;a href="https://pgrls.github.io/pgrls-docs/" rel="noopener noreferrer"&gt;pgrls.github.io/pgrls-docs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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