<?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: Supero</title>
    <description>The latest articles on DEV Community by Supero (supero).</description>
    <link>https://dev.to/supero</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%2F14744%2Fa2bc287a-4370-45ca-a25b-3a47a50d23c2.png</url>
      <title>DEV Community: Supero</title>
      <link>https://dev.to/supero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/supero"/>
    <language>en</language>
    <item>
      <title>AI app builders and multi-tenant SaaS: the 30% you still write yourself</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:49:41 +0000</pubDate>
      <link>https://dev.to/supero/ai-app-builders-and-multi-tenant-saas-the-30-you-still-write-yourself-2clg</link>
      <guid>https://dev.to/supero/ai-app-builders-and-multi-tenant-saas-the-30-you-still-write-yourself-2clg</guid>
      <description>&lt;p&gt;&lt;em&gt;Written for: developers who built something real with Lovable, Bolt or v0 and are now being asked to make it multi-tenant.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is Part 2 of &lt;strong&gt;The Last 30%&lt;/strong&gt; — a series about the part of an app you still write yourself. &lt;a href="https://dev.to/supero/supabase-rls-is-great-heres-the-part-youll-still-build-yourself-59ea"&gt;Part 1 was about Supabase RLS&lt;/a&gt;: it protects &lt;em&gt;rows&lt;/em&gt;, and Postgres's answer for &lt;em&gt;columns&lt;/em&gt; — grants — is per database role rather than per JWT claim, which is where it stops fitting. This one is the same argument one altitude up. Part 1 asked what the database doesn't cover. Part 2 asks what generation doesn't produce at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; AI app builders are excellent at code with high pattern density and &lt;em&gt;local&lt;/em&gt; correctness — a component, a form, a schema, a page. They are weak at properties that are &lt;em&gt;global&lt;/em&gt;: authorization, multi-tenant isolation, compensating transactions, idempotency, and audit. Generation is local. Invariants are global. That mismatch is the entire gap, and it is invisible in a working demo — which is why nobody catches it until the second customer, or the first refund.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Disclosure: I work on &lt;a href="https://supero.dev/?utm_source=devto&amp;amp;utm_campaign=vs-ai-builders" rel="noopener noreferrer"&gt;Supero&lt;/a&gt;, which is in this category and splits the problem differently. There is a section below on where these tools beat us, because a comparison that claims a clean sweep isn't worth your time.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Type a prompt into Lovable, Bolt or v0 and ninety seconds later you have a working app. Real React, sensible components, a layout better than most of us produce by hand, deployed to a URL you can send to someone. The reflexive engineer dismissal of that is wrong, and this post isn't it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why some code generates beautifully
&lt;/h2&gt;

&lt;p&gt;Generation works spectacularly on things with &lt;strong&gt;high pattern density&lt;/strong&gt; and &lt;strong&gt;local correctness&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A React component. Thousands of examples in training, obvious success criteria, and wrong is &lt;em&gt;visibly&lt;/em&gt; wrong.&lt;/li&gt;
&lt;li&gt;A CRUD form. Even more examples.&lt;/li&gt;
&lt;li&gt;A landing page. The model has seen a million.&lt;/li&gt;
&lt;li&gt;A schema for a blog. There is almost a canonical answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What those share is a property worth naming, because it's the whole mechanism: &lt;strong&gt;you can look at the output and tell whether it's right.&lt;/strong&gt; Tight feedback loop, local correctness, and 95% is fine — because the last 5% is visible and quick to fix.&lt;/p&gt;

&lt;p&gt;Here is the shape of handler you get. Not a strawman; this is good code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readable, typed, paginated, correct ordering. Reviewed on its own it passes. The problem only exists in relation to code that isn't on screen: there's no tenant predicate, and &lt;em&gt;you cannot see that from here&lt;/em&gt;. You can only see it by knowing what every other handler in the app does.&lt;/p&gt;

&lt;p&gt;Add the predicate and the local problem goes away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the whole ballgame
&lt;/span&gt;              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now notice what you actually bought. Not isolation — &lt;em&gt;one isolated endpoint&lt;/em&gt;. Isolation is only true if that line is true of &lt;strong&gt;every&lt;/strong&gt; query, including the one written six months from now by someone who read none of the first fifty. That's a global property, and no amount of per-prompt generation produces one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list of things that don't generate, and why it's always the same list
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The gap&lt;/th&gt;
&lt;th&gt;Why generation misses it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Authorization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not login — login is a form and a token, and generates fine. Authorization is &lt;em&gt;who may see which rows and fields, under which conditions&lt;/em&gt;, and it's a property of your whole query surface. Globally correct, or not correct at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-tenant isolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;True only if true of every query ever written against the schema.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compensating transactions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The charge succeeded; the order insert failed. What reverses it? Often a surprising amount of code, exercised only in failure, with no visual signal when it's absent.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State machines that refuse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;refunded → paid&lt;/code&gt; should be a &lt;code&gt;409&lt;/code&gt; and an unchanged record. The generated answer is usually an &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idempotency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The network retried. Did the customer get charged twice? Nothing in the output tells you.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not "do you log" — &lt;em&gt;can you answer, in six months, who accessed this record.&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those is &lt;strong&gt;invisible in a working demo.&lt;/strong&gt; They're properties of the system under failure, under adversarial use, or under time. You cannot look at the screen and see that compensation is missing. That's the difference between the two columns:&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc67b7l0oxyozu692hru.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyc67b7l0oxyozu692hru.png" alt="Isolation is a property of every query, not of one handler: the same four endpoints, with the tenant predicate repeated per handler versus resolved once in a shared data layer&lt;br&gt;
" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the number:&lt;/strong&gt; "70/30" is my estimate from watching teams take generated apps to production, not a measured statistic, and I'd rather label it than have it quoted back as one. The proportion is arguable. The &lt;em&gt;kind&lt;/em&gt; of thing in the right-hand column is not, and the kind is the point.&lt;/p&gt;
&lt;h2&gt;
  
  
  Credit where it's due: one of those six gets real attention
&lt;/h2&gt;

&lt;p&gt;"They skip authorization" would be a tidy line for me to write and it isn't accurate, so I'm not writing it.&lt;/p&gt;

&lt;p&gt;Lovable runs a &lt;strong&gt;Quick scan&lt;/strong&gt; automatically every time the publish dialog opens: it checks your database access rules and flags tables with no row-level security and rules that let everyone through. A separate &lt;strong&gt;Deep scan&lt;/strong&gt; reviews your application code — including server code that bypasses the rules your database enforces — but it does &lt;em&gt;not&lt;/em&gt; run in that dialog; you trigger it from the Security view. Both are free on every plan, and a workspace can switch on a setting that blocks publishing while critical findings are unresolved. That is a shipped feature and it is more than most of this category ships. Lovable also creates RLS policies itself when it builds a feature that stores user data. Its own documentation is candid about the ceiling — these tools "cannot guarantee complete security" — and recommends a professional review for anything sensitive.&lt;/p&gt;

&lt;p&gt;Two things remain true, and they're more interesting than the tidy line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The policy a builder reaches for by default is per-row ownership, not tenant isolation.&lt;/strong&gt; &lt;code&gt;user_id = auth.uid()&lt;/code&gt; is not a tenant boundary. RLS &lt;em&gt;can&lt;/em&gt; express organisation membership — &lt;a href="https://dev.to/supero/supabase-rls-is-great-heres-the-part-youll-still-build-yourself-59ea"&gt;Part 1&lt;/a&gt; opens with a policy that does, and calls it a real security boundary. What it can't do is guarantee that the org-scoped version, rather than the owner-scoped one, is what got written on every table — including the table added after the prompt that created the first fifty. And it says nothing at all about a client holding a service-role key that bypasses every policy you wrote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completeness is tooled. Correctness is not.&lt;/strong&gt; A scanner can tell you &lt;em&gt;which tables&lt;/em&gt; have no rule — Lovable's publish scan does, and so does Supabase's Security Advisor, and you should use both. What no scanner tells you is whether the rule you wrote expresses &lt;em&gt;your&lt;/em&gt; model: that &lt;code&gt;user_id = auth.uid()&lt;/code&gt; is the right predicate for an org-scoped app, that a tenant-scoped role is honoured, and that the application layer above re-derives the same decision. The disclosure behind the CVE below says precisely this about Lovable's publish check — it "will help ensure that RLS policies are enabled in all tables and notify if they aren't (but doesn't necessarily indicate if they are sufficient)". Table-level completeness is tooled. Semantic correctness across your whole query surface is the part you still own.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The CVE, stated precisely
&lt;/h2&gt;

&lt;p&gt;The stakes here are documented rather than hypothetical — but the record needs quoting exactly, because the imprecise version of this story is everywhere and it's unfair.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757" rel="noopener noreferrer"&gt;CVE-2025-48757&lt;/a&gt;.&lt;/strong&gt; CVSS 3.1 base score &lt;strong&gt;9.3 CRITICAL&lt;/strong&gt;, vector &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N&lt;/code&gt; — assigned by the CNA and carried in the NVD record as a &lt;em&gt;Secondary&lt;/em&gt; metric. NVD itself has published no score: &lt;code&gt;vulnStatus&lt;/code&gt; is &lt;code&gt;Deferred&lt;/code&gt;, meaning NVD will not analyse the entry. Published 2025-05-30, last modified 2026-06-17. NVD's description, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites. NOTE: this is disputed by the Supplier because each individual customer of the Lovable platform accepts a responsibility over protecting the data of their application.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five things you should take from that, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is bounded in the past.&lt;/strong&gt; The affected range stops at 2025-04-15, and the entry marks every other version &lt;code&gt;unknown&lt;/code&gt; rather than affected. Exploitation required the project to be using an &lt;em&gt;external&lt;/em&gt; database as its backend. Nothing here says anything about Lovable's security today, and I'm not implying it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance, because precision cuts both ways.&lt;/strong&gt; The finding was disclosed by Matt Palmer at &lt;strong&gt;Replit, Inc.&lt;/strong&gt; — a Lovable competitor, as am I. His own submitted vector was scope-unchanged and scored &lt;strong&gt;8.26&lt;/strong&gt;, not the 9.3 the CNA filed. Lovable acknowledged it in three days and shipped a patch on &lt;strong&gt;2025-04-24&lt;/strong&gt;, five weeks before public disclosure. Every one of those facts cuts against the scary version of this story, and all three are in the record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NVD carries it with a &lt;code&gt;disputed&lt;/code&gt; tag&lt;/strong&gt; (alongside &lt;code&gt;exclusively-hosted-service&lt;/code&gt;), with &lt;code&gt;vulnStatus: Deferred&lt;/code&gt;. The supplier's position is in the quote: each customer accepts responsibility for protecting their own application's data. You can agree or disagree with that division of responsibility — I'd just rather you read it than take my summary of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The widely-quoted "170+ affected apps" is the disclosing researcher's scan&lt;/strong&gt;, not Lovable's figure, and I have not independently reproduced it. If you cite it, cite it that way. Two fields in the same entry carry more weight than any count: it is classified &lt;strong&gt;CWE-863, Incorrect Authorization&lt;/strong&gt; — the taxonomy's own name for this layer — and the CISA Coordinator's SSVC assessment records &lt;code&gt;exploitation: proof-of-concept&lt;/code&gt;, &lt;code&gt;automatable: yes&lt;/code&gt;, &lt;code&gt;technical impact: partial&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reason to mention a disputed, historical CVE at all&lt;/strong&gt; is that it is the clearest public evidence of &lt;em&gt;where&lt;/em&gt; generated apps break. Not in the components. In the layer that has to be globally true. Lovable is also the only one of the three with a public entry of this kind, and that is not evidence it is the weakest — it is evidence that someone looked, told the vendor, and wrote it down, which is more than exists for the other two. Read the asymmetry as a gap in the public record, not as a ranking.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Four tests, about an hour, useful regardless of what you build on
&lt;/h2&gt;

&lt;p&gt;This is the part I'd actually do before the next planning meeting. None of it involves me or my product.&lt;/p&gt;

&lt;p&gt;**1. Be the second tenant. Create two orgs, two users. Use a domain you control for the fixtures, and reserved-for-fiction phone numbers (US 555-0100 to 555-0199, UK 020 7946 0xxx) — if a list or export route does return 200 when it shouldn't, you don't want it reaching a real person. With tenant A's token, request a record id owned by tenant B:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TENANT_A_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://your-app.example/api/invoices/&lt;span class="nv"&gt;$ID_OWNED_BY_TENANT_B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;403&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; is right. &lt;code&gt;200&lt;/code&gt; is the whole post. Then do it again on a &lt;em&gt;list&lt;/em&gt; endpoint with a filter parameter, and again on whatever search or export route exists — those are the ones that get added later and miss the predicate.&lt;/p&gt;

&lt;p&gt;Then check the paths that don't go through the API at all. An uploaded file often sits in storage behind a URL of its own, so a route can return a clean 403 while the object is fetchable beside it — request tenant B's file by its URL directly. Same for background work: exports, scheduled emails and webhook handlers usually run with a service key and no signed-in user, so the tenant has to come from the job's own data. Trigger an export as tenant A and read what lands in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Break a payment in the middle.&lt;/strong&gt; Do tests 2 and 3 in your payment provider's &lt;strong&gt;test mode, with test keys, against a staging tenant&lt;/strong&gt; — the point is to see the state you're left in, not to move real money. Make the write after the charge fail, on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# succeeds
&lt;/span&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;simulated failure after the charge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;         &lt;span class="c1"&gt;# never runs
&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                             &lt;span class="c1"&gt;# never runs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Money moved; no order exists. Now go look for the code that reverses the charge. If you can't find it in under two minutes, it isn't there — and it is often a surprising amount of code, enough that teams defer it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Retry a charge.&lt;/strong&gt; Fire the same mutation twice and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your-app.example/api/orders/42/capture &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check the payment provider's dashboard for two charges. Two &lt;code&gt;200&lt;/code&gt;s and one charge is fine (something short-circuited). Two &lt;code&gt;200&lt;/code&gt;s and two charges is a bug with a customer attached to it. While you're there, set an order to &lt;code&gt;refunded&lt;/code&gt; and &lt;code&gt;POST&lt;/code&gt; the pay transition — you want a &lt;code&gt;409&lt;/code&gt; and a record that didn't move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Ask the audit question.&lt;/strong&gt; Not "do we log" — run the actual query a security reviewer will ask you for:&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;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;record_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invoice'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'read'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns zero rows because nothing ever wrote a read event, you have &lt;strong&gt;write-side audit only&lt;/strong&gt;. That's worth knowing in advance of the conversation rather than during it. (Keep reading — so do we.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways teams handle the 30%
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write it yourself.&lt;/strong&gt; Correct, and it's the part that takes most of the time — on a codebase you didn't design, whose conventions you're inferring as you go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship without it.&lt;/strong&gt; Extremely common, and genuinely fine until it isn't. Internal tools and prototypes do not need compensating transactions. The problem is that prototypes get promoted, and nobody ever schedules the sprint where the prototype becomes a product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put it in the platform.&lt;/strong&gt; Don't generate the invariants — inherit them. Generate what generates well, and let the rest be a structural property of the thing your app runs on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we split it, including the rows I'd rather not print
&lt;/h2&gt;

&lt;p&gt;Supero generates the same layer everyone generates: data model, CRUD REST API, a generated client library, OpenAPI docs, admin console, customer web UI, deployed and running. Be precise about that client library, because our own API reference is: it is &lt;strong&gt;not typed.&lt;/strong&gt; The reference says in as many words that the generated client &lt;em&gt;documents&lt;/em&gt; your fields and gives you no compile-time or editor-time checking of them — a misspelled type name is still a runtime failure. &lt;strong&gt;Python is the one you can actually install&lt;/strong&gt; (&lt;code&gt;supero&lt;/code&gt;, on PyPI, and its own classifier says beta). The JavaScript package our docs tell you to &lt;code&gt;npm install&lt;/code&gt; is not published, so that command 404s today. Go and Java are registry stubs, and the failure mode is the one this whole post is about: ask for &lt;code&gt;java&lt;/code&gt; and you get &lt;code&gt;202 queued&lt;/code&gt; and then a Python and a JavaScript artifact, with nothing in the response marking the language as unavailable. The rest is &lt;em&gt;underneath&lt;/em&gt;, not generated:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The invisible part&lt;/th&gt;
&lt;th&gt;How it's provided&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant isolation&lt;/td&gt;
&lt;td&gt;Structural, on the path that matters: for CRUD reads the tenant is derived from the authenticated principal in exactly one function, and a CI gate fails the build if any read path stops delegating to it. That covers the API your app and your users go through. It does not cover background work — scheduled jobs, webhook handlers and connector runs authenticate as a system identity that sits above the tenant boundary, the same way a service role key does, and where they act on one tenant it travels as job data. Our own published multi-tenant checklist scores that honestly and I'll borrow its words — &lt;strong&gt;a developer cannot leak by forgetting, and can leak by being provisioned wrong.&lt;/strong&gt; Two documented ways: the admin tier has standing exemptions, and &lt;strong&gt;anyone left in the auto-created &lt;code&gt;default-tenant&lt;/code&gt; gets project-wide read across every other tenant in that project, whatever their role&lt;/strong&gt; — so a real customer must never be put there. On a connected &lt;em&gt;external&lt;/em&gt; source the binding is per table, and it's worse than configuration you can get wrong: a live table created through the console's wizard is &lt;strong&gt;always saved with a &lt;code&gt;fixed&lt;/code&gt; tenant binding whatever you type into the tenant-routing panel&lt;/strong&gt;, and a fixed binding does no per-row filtering at all. &lt;strong&gt;The policy layer sitting on top of all that is not deny-by-default:&lt;/strong&gt; an entity with no access policy gets full create, read, update &lt;em&gt;and&lt;/em&gt; delete rather than a refusal. Deny-by-default isn't a switch we ship — it's something you build, an access policy per role with &lt;code&gt;default_access: "none"&lt;/code&gt; plus a rule per entity you want to allow — and that object policy is evaluated &lt;strong&gt;on writes&lt;/strong&gt;: it is not consulted on a read, so setting an entity to &lt;code&gt;"none"&lt;/code&gt; stops a role writing it and leaves the records readable. Separately, our platform-, domain- and project-admin roles bypass access policies outright, and that tier includes &lt;strong&gt;project-scoped API keys&lt;/strong&gt;, not just human super-admins — so a key you mint for your own integration is not constrained by the policy layer. The tenant admin inside your app does not bypass. Part 1 carried the same admission.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field permissions&lt;/td&gt;
&lt;td&gt;A field a caller can't see can't be &lt;code&gt;distinct&lt;/code&gt;-ed, grouped by or aggregated over, including via a raw pipeline — enforced on the query path, not only on the response, and asserted end to end by &lt;code&gt;test_53_access_policy_e2e&lt;/code&gt;. Sort and filter are a different story, and the honest version is worse than the hedge I had here before: they're guarded on a &lt;strong&gt;live external source&lt;/strong&gt;, and &lt;strong&gt;on our own storage they are not.&lt;/strong&gt; The guard that would refuse them is deployed in &lt;strong&gt;monitor mode&lt;/strong&gt; — it records that it would have denied the read, then returns the rows anyway — so an &lt;code&gt;order_by&lt;/code&gt; on a hidden field gets you a &lt;code&gt;200&lt;/code&gt; and the argmax row. That's published in full, with the request that demonstrates it, and it's Part 3 of this series: &lt;a href="https://docs.supero.dev/articles/field-level-permissions-order-by" rel="noopener noreferrer"&gt;permissions that leak through &lt;code&gt;ORDER BY&lt;/code&gt;&lt;/a&gt;. &lt;strong&gt;The response-side strip is defence in depth, not the strong half:&lt;/strong&gt; it falls through on a policy-resolution failure, a missing session context, an empty policy and an unexpected error, and credential-shaped fields are held back by a separate hard-coded denylist rather than by the policy engine.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role escalation&lt;/td&gt;
&lt;td&gt;Custom roles carry a permission ceiling: they can't shadow a builtin, exceed the creator's base, or forge &lt;code&gt;is_builtin&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compensation&lt;/td&gt;
&lt;td&gt;A saga orchestrator — step 4 fails, steps 3, 2, 1 reverse, as platform code rather than yours. &lt;strong&gt;Reversal depends on each operation declaring a correct inverse, and fourteen of ours are currently mis-declared.&lt;/strong&gt; When an inverse can't be built the walker refuses to fire it and stops — which abandons the reversal of every &lt;em&gt;earlier&lt;/em&gt; step in that saga too, not just the one. We found them because the suite that was supposed to cover them had been exercising its own fixture rather than the real manifests. Ask us which operations are affected before you build a money path on one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid transitions&lt;/td&gt;
&lt;td&gt;State machines return &lt;code&gt;409&lt;/code&gt; and leave the record alone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;td&gt;A state-guarded transition refuses an out-of-state repeat, so a retry doesn't write the record twice. Two things it is &lt;strong&gt;not&lt;/strong&gt;: we are not the payment processor — we record the charge your app made at Stripe or PayPal, so a duplicate &lt;em&gt;charge&lt;/em&gt; is prevented by the idempotency key you send them, not by us; and the guard is read-then-write with no lock, so two genuinely concurrent retries can both pass it. Cumulative operations like partial refunds need your own key either way.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Write-side only. We can tell you who wrote a record. We cannot tell you who read one.&lt;/strong&gt; And check the plan before you count on even that half: the audit log is bundled from Pro up and is a $49/mo add-on below that, so on a free or entry plan it isn't write-side-only, it's absent.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Several of those rows are admissions, not features, and they are the ones I'd raise first rather than a complete list. &lt;strong&gt;Audit&lt;/strong&gt; is on my own list of six, so leaving it out of our own table would have made this post a sales page — it's the gap I'd raise first if I were evaluating us, and test 4 above will find it. &lt;strong&gt;Permissive-by-default access&lt;/strong&gt; is the other, and the test for it is &lt;em&gt;not&lt;/em&gt; test 1. Test 1 is the one the shared data layer is built to pass, which is exactly why it won't show you this: run it and you get a clean &lt;code&gt;403&lt;/code&gt;. (Not a flawless pass either — see the &lt;code&gt;default-tenant&lt;/code&gt; and admin-exemption admissions in the isolation row above.) Create a second non-admin user inside your &lt;em&gt;own&lt;/em&gt; tenant, leave an entity with no access policy, and try to list, edit and delete a row belonging to the first user. Ours returns the row and lets you write it. I'd rather you hit both here than after signing something.&lt;/p&gt;

&lt;p&gt;The fine print, since you'd find it anyway: connector &lt;strong&gt;write-back in place&lt;/strong&gt; runs only in &lt;strong&gt;live read-write&lt;/strong&gt; mode, it is implemented for Postgres (Supabase included) and MySQL, and it is &lt;strong&gt;validated end to end on Postgres only&lt;/strong&gt; — our own supported-sources matrix says MySQL live write-through has not been verified end to end, so don't take my word for it in either direction. In sync mode a connector is a copy: an edit on our side returns a &lt;code&gt;200&lt;/code&gt; and never reaches your database. There &lt;em&gt;is&lt;/em&gt; a signal — we stamp an &lt;code&gt;access_mode&lt;/code&gt; on the response — but it is &lt;strong&gt;per connector, not per table&lt;/strong&gt;, and when one connector mixes modes the most permissive one wins, so that stamp can read live-rw for a write that actually landed in our local copy. We're &lt;strong&gt;designed for SOC 2 controls&lt;/strong&gt; — not certified against SOC 2, ISO 27001 or PCI DSS. On HIPAA, read our compliance page rather than my summary: &lt;strong&gt;no Business Associate Agreement is offered&lt;/strong&gt;, and that page tells you in as many words not to put protected health information into Supero Cloud on the strength of it. Read audit is the control a HIPAA reviewer asks about first, which is precisely the row above. Custom domains: the code is built but dark, and our published position is blunter than that — &lt;strong&gt;there is no self-service flow on any deployment target&lt;/strong&gt;, no DNS automation and no certificate provisioning anywhere in the deploy path, we publish no timeline, and the $25/project/month custom-domain line in our own catalog does not enable one if you buy it. A &lt;strong&gt;scheduled connector sync&lt;/strong&gt; you can configure and save, and it validates — but whether the runner actually fires it depends on the deployment, so confirm a real run in Run History before you depend on one. And our source export is &lt;strong&gt;one-way&lt;/strong&gt;, in the two senses spelled out below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The distinction that actually matters:&lt;/strong&gt; the platform provides these; the prompt does not emit them. They aren't better-generated code. They're code written once, by people, tested adversarially, that your generated app runs on top of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Lovable, Bolt and v0 beat us
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Visual quality.&lt;/strong&gt; They're better. The design-first builders produce interfaces that look considered; ours look like competent conventional software. If the interface &lt;em&gt;is&lt;/em&gt; the product, use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed of first result.&lt;/strong&gt; Theirs is seconds — a sign-up and a free token budget, no access request. Ours is a free signup (no card) and then under a minute to a few minutes. The deployment story is the real gap, and it deserves stating in the direction that's fair to both sides: our free plan &lt;em&gt;does&lt;/em&gt; hand you a public preview URL, but it's reaped after about thirty minutes, and a deployment that stays up starts at &lt;strong&gt;$9.99/mo&lt;/strong&gt; (listed at $19.99, currently half off) where theirs is a public URL on the free plan. That is a real disadvantage when someone is evaluating three tools on a Thursday afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration feel.&lt;/strong&gt; The prompt-see-adjust loop on a UI is tight and pleasant in a way that generation over a governed backend is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code you own from second one.&lt;/strong&gt; All three say you own the code they generate — read each one's terms, they word it differently — and their access models differ in ways worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bolt&lt;/strong&gt; has the most straightforward path: project title → Export → Download hands you a &lt;code&gt;.zip&lt;/code&gt; of your project files — code, not the database or your environment variables. Its docs state no plan requirement for it, which is worth noting because the same docs &lt;em&gt;do&lt;/em&gt; say custom domains and bringing your own Supabase are paid-only. Its GitHub integration also runs both ways: it auto-commits your changes and polls GitHub every 30 seconds for commits made outside Bolt (its docs note Bolt's copy wins a simultaneous-edit conflict).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable&lt;/strong&gt; gates full codebase download to paid plans, but Git sync is a genuine &lt;strong&gt;two-way&lt;/strong&gt; sync to a repo you own on GitHub, GitLab (including a self-managed instance) or Bitbucket, on any plan, with GitHub Enterprise Cloud/Server on the Enterprise plan. It exports from Lovable; it won't import an existing repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v0&lt;/strong&gt; doesn't need a repository at all — Vercel's docs say Git integration is optional, and projects without GitHub deploy their current code straight to Vercel. Connect one and v0 &lt;em&gt;creates&lt;/em&gt; a private repo in an account or org you choose, and that sync is bi-directional. Publishing goes to Vercel, though the docs say you can export the code and deploy elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Check each one's current docs before you rely on any of that.&lt;/strong&gt; It's the fastest-moving column in the comparison and any of it may have changed since the date at the bottom of this post. None of the three is relationship-free, but the defaults are not what the lazy version of this sentence says: Bolt's default home is its own Cloud (&lt;code&gt;.bolt.host&lt;/code&gt; plus a Bolt database on every plan; Netlify is the documented alternative, and using your own Supabase account needs a paid plan), Lovable now ships its own managed Postgres with Supabase as an optional connection, and v0 publishes to Vercel though its docs say you can export and host elsewhere.&lt;/p&gt;

&lt;p&gt;Their code access is more open than ours in two ways, not one. Bolt's and Lovable's and v0's Git syncs all run both directions where our export is &lt;strong&gt;one-way&lt;/strong&gt; — you get the code, your edits to it don't come back. And their output runs on its own where ours doesn't: the code we hand you proxies its &lt;code&gt;/api/*&lt;/code&gt; calls back to Supero, so it needs Supero behind it — our cloud, or your own AWS or GCP account where the UI is yours and the backend still calls us, or a licensed install on your own servers, which is an Enterprise arrangement we have not yet published a customer runbook for. Worth saying plainly — the tightest coupling in this comparison is ours. If walking away with a standalone codebase is the requirement, they win that outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The use-case split, stated plainly:&lt;/strong&gt; marketing site, landing page, design-led consumer app, or a prototype for an investor next week — use them. That's the correct recommendation and we'd be the worse tool for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the 30% starts to matter
&lt;/h2&gt;

&lt;p&gt;It isn't a size threshold. It's a set of events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;second customer organisation&lt;/strong&gt; logs in, and neither may see the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money moves&lt;/strong&gt;, so a half-applied sequence becomes a reconciliation problem instead of a bug.&lt;/li&gt;
&lt;li&gt;A customer's &lt;strong&gt;security review&lt;/strong&gt; asks how field permissions are enforced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated data&lt;/strong&gt; arrives — anything where a histogram is a leak.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;second developer&lt;/strong&gt; joins, and invariants stop being enforced by one person's memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the first of those, this post is a distraction. After it, retrofitting means auditing every query path and then installing a mechanism that makes the next one safe. That is expensive, and it is the one piece of work you cannot do file by file in isolation — but it is a refactor with a checklist, not a rewrite. Part 1's audit query is the Supabase-shaped version of that checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can Lovable or Bolt build a multi-tenant SaaS?&lt;/strong&gt;&lt;br&gt;
They can generate the pieces, including Supabase RLS policies, and Lovable's publish scan will tell you which tables ended up with no rule at all. What's missing is anything that checks the rules you got express &lt;em&gt;your&lt;/em&gt; tenancy model across the whole query surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Bolt or Lovable production ready?&lt;/strong&gt;&lt;br&gt;
For the frontend, and for single-tenant apps, frequently yes. The question to ask isn't about code quality — it's about the invariants: isolation, compensation, idempotency, audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does an AI app builder not generate?&lt;/strong&gt;&lt;br&gt;
Anything globally correct rather than locally correct: authorization across the whole query surface, tenant isolation, compensating transactions, idempotency, and read audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use an AI builder for the frontend and something else for the backend?&lt;/strong&gt;&lt;br&gt;
Yes, and it's a sensible split: generate the UI where generation is strongest, and put the invariants in a layer that enforces them for every query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test for the missing 30%?&lt;/strong&gt;&lt;br&gt;
The four tests above. Fail a step in the middle of a payment flow and see what state you're left in; retry a charge and check for a double; log in as a second tenant and try to read the first; run the read-audit query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3, which is already published
&lt;/h2&gt;

&lt;p&gt;Part 3 goes after the leak itself: &lt;strong&gt;how field permissions escape through &lt;code&gt;ORDER BY&lt;/code&gt; and aggregates&lt;/strong&gt; — queries that are individually correct, return no hidden field, pass review, and still let a caller binary-search a value they were never allowed to see. It's up, and it includes the request that demonstrates the gap against our own storage: &lt;a href="https://docs.supero.dev/articles/field-level-permissions-order-by" rel="noopener noreferrer"&gt;Your field-level permissions probably leak through ORDER BY&lt;/a&gt;. If you ran test 1 above and got clean &lt;code&gt;403&lt;/code&gt;s, that's the test that will surprise you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audit your generated app against the table above.&lt;/strong&gt; Six rows, an afternoon, and it's useful regardless of what you end up building on. Nothing in it requires talking to me.&lt;/p&gt;

&lt;p&gt;Then, if you want to see what the other approach produces, &lt;strong&gt;applications generated by the platform are live and need no account&lt;/strong&gt; — sixteen at the time of writing, with the current list at &lt;code&gt;api.supero.dev/api/v1/public/showcase&lt;/code&gt;: &lt;a href="https://concierge.supero.live" rel="noopener noreferrer"&gt;concierge.supero.live&lt;/a&gt; · &lt;a href="https://atelier.supero.live" rel="noopener noreferrer"&gt;atelier.supero.live&lt;/a&gt; · &lt;a href="https://ledgerline.supero.live" rel="noopener noreferrer"&gt;ledgerline.supero.live&lt;/a&gt;. Two fair warnings. They scale to zero, so the first load is waking a cold container — give it ten seconds or so, and an idle one occasionally won't wake on the first try. And most are &lt;strong&gt;single-tenant public demos&lt;/strong&gt; — they show you the &lt;em&gt;generated application&lt;/em&gt;, the data model and the screens, not the governance this post argues about.&lt;/p&gt;

&lt;p&gt;One exception is worth your time. &lt;a href="https://sentinel.supero.live" rel="noopener noreferrer"&gt;sentinel.supero.live&lt;/a&gt; runs &lt;strong&gt;two insurers on one deployment&lt;/strong&gt; — Northwind Mutual and Cascade Assurance — with four published logins across both tenants, so you can run test 1 against us directly. The two tenants use different claim-number prefixes, so a leak would be visible on sight. Same cold start as the others, so give the first page a few seconds. The login page hands you a working, shared, writable account, so treat anything you create there as public.&lt;/p&gt;

&lt;p&gt;And if you'd rather read the output than click through it, the reference apps are on GitHub — MIT-licensed, readable Python and JS, nineteen of them: &lt;a href="https://github.com/supero-platform/supero-apps" rel="noopener noreferrer"&gt;github.com/supero-platform/supero-apps&lt;/a&gt;. That's the honest test of the first half of this post: the generated layer is real code you can inspect in a browser tab. What you won't see in that repo is the part this whole piece is about — the invariants don't live in the generated files, which is rather the point.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://supero.dev/?utm_source=devto&amp;amp;utm_campaign=vs-ai-builders" rel="noopener noreferrer"&gt;Start free&lt;/a&gt; if the generated output is the standard you'd ship. One number the pricing page doesn't print, and you'll want it before you judge us on output: the free plan's budget is three app generations a month.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Lovable, Bolt and v0 capabilities checked &lt;strong&gt;2 October 2026&lt;/strong&gt; against each vendor's own documentation — &lt;code&gt;docs.lovable.dev/features/security&lt;/code&gt;, &lt;code&gt;docs.lovable.dev/integrations/git-sync-overview&lt;/code&gt;, &lt;code&gt;support.bolt.new&lt;/code&gt; and &lt;code&gt;v0.app/docs&lt;/code&gt; (v0's pages are stamped 1 October 2026); CVE data from the NVD API the same day. This category changes monthly — if something here is out of date or wrong, tell me in the comments and I'll correct it. A comparison with an error in it is worse than no comparison.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Supabase RLS is great. Here's the part you'll still build yourself.</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Mon, 28 Sep 2026 17:30:04 +0000</pubDate>
      <link>https://dev.to/supero/supabase-rls-is-great-heres-the-part-youll-still-build-yourself-59ea</link>
      <guid>https://dev.to/supero/supabase-rls-is-great-heres-the-part-youll-still-build-yourself-59ea</guid>
      <description>&lt;p&gt;If you're building multi-tenant SaaS on Supabase, you probably picked it for one very good reason: Row Level Security. And you were right to.&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="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'app_metadata'&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'org_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That policy is a real security boundary. Postgres appends it whether or not you remembered a &lt;code&gt;WHERE&lt;/code&gt; — and it holds the same for PostgREST, the JS client, a &lt;code&gt;psql&lt;/code&gt; session, and a 3 a.m. background job. That's categorically stronger than filtering in an ORM, and it's the single best reason to build multi-tenant SaaS on Supabase.&lt;/p&gt;

&lt;p&gt;I'm not going to argue with any of that. I'm going to point at the four things it doesn't cover — because you'll meet all four eventually, and it's better to meet them now than in month nine.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Full disclosure: I work on Supero, which competes with Supabase (&lt;a href="https://www.supero.dev/?utm_source=devto&amp;amp;utm_campaign=vs-supabase" rel="noopener noreferrer"&gt;https://www.supero.dev/?utm_source=devto&amp;amp;utm_campaign=vs-supabase&lt;/a&gt;). This is a critique of a structural property of RLS, not of Supabase's implementation, which is genuinely good.)&lt;/em&gt;&lt;/p&gt;

&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%2Fcqhvcumowemfjnjq8n6t.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%2Fcqhvcumowemfjnjq8n6t.gif" alt=" " width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RLS answers "which rows." Not "which columns, for whom."
&lt;/h2&gt;

&lt;p&gt;RLS is row-level. It says nothing about columns. Postgres &lt;em&gt;does&lt;/em&gt; have an answer for columns — grants:&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;select&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;employee&lt;/span&gt; &lt;span class="k"&gt;from&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;employee&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;Those are checked against every column your query touches — including &lt;code&gt;ORDER BY&lt;/code&gt;. But they're &lt;strong&gt;per database role&lt;/strong&gt;, not per JWT claim. So the moment your requirement becomes &lt;em&gt;"managers see salary, staff don't, and both log in as &lt;code&gt;authenticated&lt;/code&gt;,"&lt;/em&gt; grants stop fitting. You reach for a view per audience:&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;view&lt;/span&gt; &lt;span class="n"&gt;employee_staff&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&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;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dept&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;employee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Views work. They also multiply — one per audience per table, each needing maintenance every time the base table changes. Everyone who's done this remembers the migration where they added a column and found seven views to update, and missed one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part worth stating precisely&lt;/strong&gt; (because it's easy to get wrong in my favour): if you hide &lt;code&gt;salary&lt;/code&gt; with a column grant or a restricted view, this is &lt;em&gt;blocked&lt;/em&gt; —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /employee?select=name&amp;amp;order=salary.desc&amp;amp;limit=1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres refuses it. A competent Supabase team using grants or views has already closed this.&lt;/p&gt;

&lt;p&gt;The leak shows up when you mask fields in &lt;strong&gt;your own API layer&lt;/strong&gt; instead — a Next.js route that fetches the row and deletes &lt;code&gt;salary&lt;/code&gt; from the JSON before returning it. That's extremely common once JWT-based, per-tenant logic arrives. And at that point the sort/filter surface is ungoverned again: &lt;code&gt;order=salary.desc&lt;/code&gt; returns the top earner's name without a salary, and &lt;code&gt;?salary=gt.200000&lt;/code&gt; with &lt;code&gt;Prefer: count=exact&lt;/code&gt; becomes an oracle that binary-searches the value. (On Supabase specifically, &lt;code&gt;distinct&lt;/code&gt; and aggregates are off by default, so two of those vectors need someone to have turned them on.)&lt;/p&gt;

&lt;p&gt;So the criticism isn't "RLS leaks." It's that &lt;strong&gt;RLS protects what stays at the database boundary — and most teams eventually stop staying there.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every policy is a policy someone has to remember to write
&lt;/h2&gt;

&lt;p&gt;RLS is opt-in per table. Credit where due: tables created in the Table Editor get it on by default. But tables created by a migration don't — &lt;code&gt;alter table … enable row level security&lt;/code&gt; is a line a human types. At sixty tables, over two years, with a rotating team, the failure mode is a missing policy on the table someone added last Tuesday.&lt;/p&gt;

&lt;p&gt;Supabase does back you up here — its Security Advisor flags a public table with RLS disabled as an &lt;em&gt;error&lt;/em&gt;. That's a good backstop; I wouldn't build without it. Two things that get less airtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Table owners bypass RLS&lt;/strong&gt; unless you set &lt;code&gt;alter table … force row level security&lt;/code&gt;. If migrations run as the owner and something reuses that role, your policies simply don't apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can audit it in one query&lt;/strong&gt; and fail CI on the result:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;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;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&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;relforcerowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_forced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polname&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;policies&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;left&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_policy&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polrelid&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;oid&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;group&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;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;having&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;is&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But notice what that combination &lt;em&gt;is&lt;/em&gt;: isolation as your team's discipline plus a linter, rather than as the architecture. Better than nothing. Weaker than structural.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;service_role&lt;/code&gt; bypasses all of it
&lt;/h2&gt;

&lt;p&gt;The most common real-world Supabase multi-tenant failure isn't a missing policy. It's the &lt;code&gt;service_role&lt;/code&gt; key — which bypasses RLS entirely — leaking into a client bundle, or getting used in an edge function because threading the user's JWT through was more work.&lt;/p&gt;

&lt;p&gt;RLS is a strong boundary with a documented master key. Whether that key stays server-side is a property of your team's process, not of the database. Rotate it, never ship it to a client, and grep your repo for it before you read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Sub-tenancy gets awkward fast
&lt;/h2&gt;

&lt;p&gt;A flat &lt;code&gt;org_id&lt;/code&gt; claim handles "customer A can't see customer B." The next asks are harder: a customer with two divisions; a reseller who needs a view across fifteen customers; a user who belongs to two orgs and switches.&lt;/p&gt;

&lt;p&gt;Each is doable — a recursive CTE over an org tree in the policy, a claims array, a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; helper. But now your isolation predicate is a non-trivial function evaluated on every row of every query — a correctness surface &lt;em&gt;and&lt;/em&gt; a performance surface. Supabase has published the performance fix (wrap &lt;code&gt;auth.uid()&lt;/code&gt; in a scalar subselect so it runs once; index the column), and it works. It's still one more thing you maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest turn
&lt;/h2&gt;

&lt;p&gt;Here's the objection this whole post has been arming you with: &lt;em&gt;if the leak lives in the application layer, and Supero is an application layer, why not just use column grants and a view per audience?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a two-audience system — honestly, do that.&lt;/strong&gt; It's free, it's older than both of us, and this post showed you how.&lt;/p&gt;

&lt;p&gt;The argument only starts paying when your audiences stop being database roles. The moment visibility depends on a JWT claim — &lt;em&gt;this&lt;/em&gt; manager, &lt;em&gt;this&lt;/em&gt; tenant, &lt;em&gt;this&lt;/em&gt; customer's own staff — column grants don't fit, because they're per-role and every logged-in user shares one role. That's the point where every team ends up building an application layer anyway. And the only question left is whether the "can this caller see this field" decision is &lt;strong&gt;computed once and applied to both the response and the query surface&lt;/strong&gt;, or computed twice and allowed to drift.&lt;/p&gt;

&lt;p&gt;That's the whole of what Supero does differently: one field decision, two enforcement points (the response &lt;em&gt;and&lt;/em&gt; what you can sort, filter, group, or aggregate by), assembled below your app across four levels — domain → project → tenant → user — with no per-table enable step to forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And I'll be straight about where we're not there yet&lt;/strong&gt;, because a comparison page that hides its own gaps isn't worth reading: our access policy is &lt;em&gt;permissive&lt;/em&gt; by default, so entities need explicit policies and generated apps can ship without them; the admin tier bypasses access policies entirely; we log who &lt;em&gt;wrote&lt;/em&gt; a record but not who &lt;em&gt;read&lt;/em&gt; one; and &lt;code&gt;order_by&lt;/code&gt;/&lt;code&gt;filter&lt;/code&gt; on a hidden field aren't yet guarded on our own storage (they are on a live external DB). If those are dealbreakers, better you know now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Supabase simply wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open source and self-hostable&lt;/strong&gt; — a stronger, more proven position than ours. If that's your top criterion, the comparison is over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's Postgres&lt;/strong&gt; — every extension, tool, DBA, and Stack Overflow answer of the last fifteen years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maturity and ecosystem&lt;/strong&gt; at a scale we don't have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct SQL&lt;/strong&gt; — the full expressive power of the database. We give you a query builder and an API; for many engineers that's a downgrade, and it's a fair objection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to actually do next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run the audit query from §2 against your own database.&lt;/strong&gt; If it returns rows, that's today's work — and it has nothing to do with me.&lt;/p&gt;

&lt;p&gt;If you then want to see what a generated, governed app looks like, sixteen of ours are live and need no account: &lt;a href="https://concierge.supero.live" rel="noopener noreferrer"&gt;concierge.supero.live&lt;/a&gt; · &lt;a href="https://atelier.supero.live" rel="noopener noreferrer"&gt;atelier.supero.live&lt;/a&gt; · &lt;a href="https://ledgerline.supero.live" rel="noopener noreferrer"&gt;ledgerline.supero.live&lt;/a&gt;. Fair warning: those are single-tenant public demos with no hidden-field policies configured, so they show you the &lt;em&gt;generated application&lt;/em&gt; — the model, the screens, the finish — not the field enforcement this post argues about. Pointing the &lt;code&gt;order=&lt;/code&gt; test at them returns &lt;code&gt;200&lt;/code&gt; because there's nothing there to refuse; that proves nothing either way.&lt;/p&gt;

&lt;p&gt;The example apps are also on GitHub — MIT-licensed, readable Python and JS you can inspect: &lt;a href="https://github.com/supero-platform/supero-apps" rel="noopener noreferrer"&gt;https://github.com/supero-platform/supero-apps&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To see the enforcement itself, come find me — I'll run the aggregate vectors against a schema with a real hidden field. Thirty minutes with an engineer, not a demo.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If anything here is wrong about Supabase, tell me and I'll fix it — a comparison with an error in it is worse than no comparison.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>What Supero doesn't do yet - the honest list!</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:42:43 +0000</pubDate>
      <link>https://dev.to/supero/what-supero-doesnt-do-yet-the-honest-list-3kb3</link>
      <guid>https://dev.to/supero/what-supero-doesnt-do-yet-the-honest-list-3kb3</guid>
      <description>&lt;p&gt;Most product updates focus on new features and what's working well. This one is about the other side: what Supero doesn't support yet.&lt;/p&gt;

&lt;p&gt;For transparency, I work on &lt;a href="https://supero.dev/?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=honest-list" rel="noopener noreferrer"&gt;Supero&lt;/a&gt;, a platform for generating governed, multi-tenant applications from a schema. These are the limitations I’d want to understand before choosing a platform like ours.&lt;/p&gt;

&lt;p&gt;Database connectors:&lt;/p&gt;

&lt;p&gt;Supero can read from external databases. Direct write-back currently works with Postgres, Supabase and MySQL.&lt;/p&gt;

&lt;p&gt;One way sync, which would copy source data into the application on a schedule, isn't available yet either. It's on the roadmap.&lt;/p&gt;

&lt;p&gt;It's also worth being clear about what we mean by one way sync. It copies source data into the application, but it doesn't reconcile records deleted from the source or push local changes back upstream.&lt;/p&gt;

&lt;p&gt;Access policies:&lt;/p&gt;

&lt;p&gt;Access policies need to be configured. Creating an entity without attaching a policy doesn't automatically lock it down, and the admin role is intentionally powerful.&lt;/p&gt;

&lt;p&gt;That gives developers control over the policy model, but it also means you need to review the generated policies instead of assuming everything is denied by default. Deny-by-default scaffolding is something we want to add, but it isn't the current behavior.&lt;/p&gt;

&lt;p&gt;Audit history:&lt;/p&gt;

&lt;p&gt;We track who created or changed a record. We don't yet track who viewed it.&lt;/p&gt;

&lt;p&gt;If your compliance requirements include read-access history, you will need to cover that separately for now.&lt;/p&gt;

&lt;p&gt;SLA and mobile:&lt;/p&gt;

&lt;p&gt;We have web applications running in production, but we don't publish an uptime SLA yet.&lt;/p&gt;

&lt;p&gt;Mobile also hasn’t had the same level of real-world use and testing as web. If mobile support or a formal SLA is essential to your application, talk to us before building around it.&lt;/p&gt;

&lt;p&gt;Free trial:&lt;/p&gt;

&lt;p&gt;You can use the free tier to generate an application and test it end to end without entering a card.&lt;/p&gt;

&lt;p&gt;It's meant for evaluation, though. Keeping an application hosted permanently requires a paid plan.&lt;/p&gt;

&lt;p&gt;What you can test today:&lt;/p&gt;

&lt;p&gt;The areas we are comfortable asking developers to test closely are tenant and role isolation enforced on the server, and field-level access that removes restricted fields from the API response rather than only hiding them in the UI.&lt;/p&gt;

&lt;p&gt;You also own the generated code. The applications use readable Python and JavaScript, and you can download them, move them to your own repository and run them on Supero Cloud, AWS, GCP or your own infrastructure.&lt;/p&gt;

&lt;p&gt;Our demos include test credentials and curl commands because we would rather let you test the access controls yourself than simply claim they work.&lt;/p&gt;

&lt;p&gt;If one of these limitations blocks your use case, let us know. That feedback helps us decide what to work on next. If you find another gap that isn't listed here, we would like to know about that too.&lt;/p&gt;

&lt;p&gt;Code and demo apps  19 MIT-licensed projects:&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/supero-platform/supero-apps" rel="noopener noreferrer"&gt;https://github.com/supero-platform/supero-apps&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try the builder:&lt;br&gt;
&lt;a href="https://supero.dev/?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=honest-list" rel="noopener noreferrer"&gt;https://supero.dev/?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=honest-list&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>The multi-tenant SaaS checklist: six decisions to make now, one to ignore</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:24:21 +0000</pubDate>
      <link>https://dev.to/supero/the-multi-tenant-saas-checklist-six-decisions-to-make-now-one-to-ignore-4o2f</link>
      <guid>https://dev.to/supero/the-multi-tenant-saas-checklist-six-decisions-to-make-now-one-to-ignore-4o2f</guid>
      <description>&lt;p&gt;The multi-tenant SaaS checklist: seven decisions, what each costs if you defer it, and the one everyone worries about far too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Decide by&lt;/th&gt;
&lt;th&gt;Cost if deferred&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;What is a tenant?&lt;/td&gt;
&lt;td&gt;Day one&lt;/td&gt;
&lt;td&gt;Rewrite every query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;How does a request resolve to a tenant?&lt;/td&gt;
&lt;td&gt;Day one&lt;/td&gt;
&lt;td&gt;The most common real breach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Where does isolation live?&lt;/td&gt;
&lt;td&gt;First paying customer&lt;/td&gt;
&lt;td&gt;Audit everything you wrote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Roles, or permissions?&lt;/td&gt;
&lt;td&gt;Customer three&lt;/td&gt;
&lt;td&gt;Fix live roles retroactively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Which fields are secret, from whom?&lt;/td&gt;
&lt;td&gt;Before your first aggregate endpoint&lt;/td&gt;
&lt;td&gt;Find every reporting query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;What happens when a workflow fails halfway?&lt;/td&gt;
&lt;td&gt;Before money moves&lt;/td&gt;
&lt;td&gt;Reconcile by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Per-tenant databases?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Wait&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(this one is fine to defer)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Disclosure: I work on Supero, which generates multi-tenant backends. I have written this so it is useful if you never touch our product; the decisions are the same on any stack. There is one linked section at the end about how we answer them, kept off this page on purpose.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What is a tenant, exactly?
&lt;/h2&gt;

&lt;p&gt;Not rhetorical. Get it wrong and every later decision inherits the error.&lt;/p&gt;

&lt;p&gt;"Tenant" usually starts as a synonym for "customer," and then reality arrives: a customer with &lt;strong&gt;two departments&lt;/strong&gt; that must not see each other; a customer who &lt;strong&gt;acquires another customer&lt;/strong&gt;; a &lt;strong&gt;reseller&lt;/strong&gt; managing fifteen accounts; a user who belongs to &lt;strong&gt;two tenants&lt;/strong&gt; and switches.&lt;/p&gt;

&lt;p&gt;With one flat &lt;code&gt;tenant_id&lt;/code&gt;, the first case forces a fake second account, the third forces an application-level join that bypasses your isolation, and the fourth forces a session hack. All three are things teams actually do, and all three are how cross-tenant leaks ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide on day one.&lt;/strong&gt; Pick a hierarchy even if you use one level of it for a year. &lt;code&gt;organisation → workspace → user&lt;/code&gt; gives you room for departments and resellers without a migration. Adding a level later means rewriting every query you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. How does a request resolve to a tenant?
&lt;/h2&gt;

&lt;p&gt;The decision most checklists skip, and the one that produces the most real vulnerabilities.&lt;/p&gt;

&lt;p&gt;Decision 3 asks &lt;em&gt;where&lt;/em&gt; isolation is enforced. This asks where the tenant identity &lt;strong&gt;comes from&lt;/strong&gt;. Subdomain? A signed JWT claim? A session lookup? Or — the one that ships breaches — an &lt;code&gt;org_id&lt;/code&gt; in the request body that the caller supplies?&lt;/p&gt;

&lt;p&gt;An isolation layer that cannot omit tenant scope is worth nothing if the scope it cannot omit was chosen by the attacker. Same for RLS keyed on a claim your API populates from user input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide on day one&lt;/strong&gt;, and write it down: the tenant identity comes from exactly one place, it is server-derived, and no request field can override it. Then grep for the places that do it differently, because there will be some.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Where does isolation live?
&lt;/h2&gt;

&lt;p&gt;Three honest answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the application.&lt;/strong&gt; Every query carries &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;. Fast to build, and it fails the moment one developer on one endpoint forgets. Not hypothetical: a statistical certainty as headcount grows. The failure is silent. 200, with too many rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the database.&lt;/strong&gt; Postgres row-level security, schema-per-tenant, or database-per-tenant. Much stronger, because forgetting is no longer possible: the database refuses. Costs are real: RLS is its own skill and can be subtly wrong; schema-per-tenant makes migrations an operational project; database-per-tenant makes cross-tenant analytics and connection pooling hard at a few hundred tenants. &lt;strong&gt;That last option is the physical tier of this same decision. See #7, and do not treat it as a separate choice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In one shared producer below the application.&lt;/strong&gt; Be clear-eyed that this is a &lt;em&gt;disciplined&lt;/em&gt; version of the first option rather than a third tier: it is middleware. What it changes is that the scope is written once instead of at forty call sites, which removes the per-endpoint mistake, the single most common way isolation actually breaks. What it does not do is make the boundary unforgettable the way the database refusing a query does. Ask any vendor claiming this (us included) two things: does the producer have bypass branches, and what stops a new call site from skipping it entirely?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide by your first paying customer.&lt;/strong&gt; Moving later means auditing every query you have ever written, and you will not find them all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test that tells you the truth:&lt;/strong&gt; ask a developer to write an endpoint that leaks across tenants. If they &lt;em&gt;can&lt;/em&gt; — if nothing stops them but their own care — that is your isolation model, and care does not scale.&lt;/p&gt;

&lt;p&gt;Apply it to us, since it would be cheap to set a test we pass. &lt;strong&gt;We do not pass it cleanly.&lt;/strong&gt; The shared producer removes the per-endpoint mistake. What it does not remove is three ways around the boundary that need no mistake at all: admin roles have documented exemptions, a user left in the platform's &lt;code&gt;default-tenant&lt;/code&gt; gets project-wide read, and a request arriving with no session context returns allow. That last one is unreachable today, because authentication runs first, but it sits in the code. The honest score is "a developer cannot leak by forgetting, and can leak by being provisioned wrong." Better than the first option on this list. Not the same as the second.&lt;/p&gt;

&lt;p&gt;The part of that we can demonstrate rather than assert is the narrow part. Re-inline the tenant scope at a call site and the gate that parses our own source fails, exit 1; leave the code alone and all 25 gates pass. That answers "can a developer write the leaking endpoint" and nothing else on the list, which is roughly the shape of evidence to demand from anyone answering this question.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4jwgjvxrg03veqptcc2.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4jwgjvxrg03veqptcc2.png" alt="Where tenant isolation lives: application, database, or query planner" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Roles, or permissions?
&lt;/h2&gt;

&lt;p&gt;Everyone starts with roles: &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;member&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;. Everyone ends up needing permissions, because the fourth customer wants someone who can see invoices but not edit users.&lt;/p&gt;

&lt;p&gt;The escape is a &lt;code&gt;custom_roles&lt;/code&gt; table, and it is the right call. Two things go wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privilege escalation through role creation.&lt;/strong&gt; If a tenant admin can create a role, they can create one with permissions they do not have, unless you enforce a &lt;strong&gt;ceiling&lt;/strong&gt;: a created role can never exceed its creator's permissions and can never shadow a builtin. Five lines, missing from a surprising number of production systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roles checked in the UI.&lt;/strong&gt; If your frontend hides a button and your API does not check, you do not have roles. You have a suggestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide by customer three.&lt;/strong&gt; Retrofitting a ceiling after tenant admins have created roles is worse, because you now have live roles violating the rule you are about to introduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Which fields are secret, and from whom?
&lt;/h2&gt;

&lt;p&gt;Made last, regretted most, and where I have watched careful teams still ship a hole.&lt;/p&gt;

&lt;p&gt;Hiding a field from a response is the easy half. &lt;strong&gt;A field nobody can see can still be used to compute.&lt;/strong&gt; If a caller cannot see &lt;code&gt;salary&lt;/code&gt; but can write &lt;code&gt;?order_by=-salary&amp;amp;limit=1&lt;/code&gt;, they read the highest-paid employee's name. &lt;code&gt;?distinct=diagnosis_code&lt;/code&gt; returns the value set, and for a low-entropy column the value set &lt;em&gt;is&lt;/em&gt; the secret.&lt;/p&gt;

&lt;p&gt;Response masking fixes none of it, because the leak is not in the body — it is in which rows came back and in what order.&lt;/p&gt;

&lt;p&gt;The fix: the fields a caller may &lt;strong&gt;filter, sort, group and aggregate by&lt;/strong&gt; must be the same set they may &lt;strong&gt;see&lt;/strong&gt;, computed in one place. Two lists maintained separately will drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide before you build your first aggregate or reporting endpoint.&lt;/strong&gt; That is where this bug is overwhelmingly introduced: aggregates get written later, by someone else, and take a raw field name.&lt;/p&gt;

&lt;p&gt;Our own read-input guard for this is deployed and running in monitor mode, where it logs &lt;code&gt;would deny read&lt;/code&gt; and returns the rows anyway. Which is to say the decision is easy to make and slow to finish, on any stack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.supero.dev/articles/field-level-permissions-order-by" rel="noopener noreferrer"&gt;The full anatomy of that attack, and what a real fix requires.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What happens when a workflow fails halfway?
&lt;/h2&gt;

&lt;p&gt;Your checkout reserves inventory, charges a card, creates an order. The charge succeeds. The insert fails.&lt;/p&gt;

&lt;p&gt;Three levels of answer. &lt;strong&gt;Nothing&lt;/strong&gt;: you find out from the customer, which is most early products, and mostly they get away with it. &lt;strong&gt;Retries and idempotency keys&lt;/strong&gt;: necessary and insufficient, because retrying forward does not undo a half-applied sequence, and a non-idempotent money operation applied twice is worse. &lt;strong&gt;Compensation&lt;/strong&gt;: step 4 fails, steps 3, 2 and 1 reverse. That is the saga pattern, and the actual answer.&lt;/p&gt;

&lt;p&gt;Three things teams underestimate. Compensation logic is roughly the same volume as the forward path and is the code least likely to be tested, because writing a test that fails step four on purpose is annoying. The reversal can itself fail. Decide now what happens then, because "the reversal didn't reverse" needs an operator, an alert and a runbook. And a compensation &lt;em&gt;engine&lt;/em&gt; is not the same as compensation &lt;em&gt;coverage&lt;/em&gt;: every individual operation needs a correct inverse declared, and the ones that don't fail silently. Audit them one by one. Ours declares 119 states and 81 transitions across 21 service manifests, 19 forward operations carry a locked inverse, and we have still found mis-wired ones in there.&lt;/p&gt;

&lt;p&gt;One precision worth having: for payments specifically, the clean pattern is authorize-then-capture, and you &lt;strong&gt;void an uncaptured authorization&lt;/strong&gt; rather than compensating a completed charge. Compensation is for the steps around the money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide before you take money.&lt;/strong&gt; Not before you launch — before money moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Single database, or one per tenant?
&lt;/h2&gt;

&lt;p&gt;The one everybody argues about first and should mostly argue about last. It is the physical half of decision 3, which is why it is here rather than earlier.&lt;/p&gt;

&lt;p&gt;For most B2B SaaS, a shared database with strong isolation is correct until you have either a customer contractually demanding physical separation or a measured noisy-neighbour problem. Both are real and both arrive later than you fear. Premature database-per-tenant costs you migrations across N databases, connection pool exhaustion, cross-tenant analytics that were one query and are now a pipeline, and a provisioning flow you now operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide when a customer asks, or when you measure a problem.&lt;/strong&gt; The one item where "later" is right.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this multi-tenant SaaS checklist leaves out
&lt;/h2&gt;

&lt;p&gt;Four more that bite, in rough order of how often I have seen them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant SSO and SCIM deprovisioning.&lt;/strong&gt; Arrives with the first enterprise deal and retrofits badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenant deletion and data residency.&lt;/strong&gt; "Delete Acme entirely from a shared database" is a legal obligation, and EU-resident tenancy is a day-one partitioning decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant rate limits and quotas.&lt;/strong&gt; Cheap now, brutal later — this article's own thesis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support impersonation.&lt;/strong&gt; "View as Acme" is the standard isolation bypass, and it is usually unaudited.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six of seven above are cheap today. That ratio is the point of the article, and it is why the seventh being deferrable matters: it is the one that looks like the big architectural decision and is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best multi-tenant architecture?
&lt;/h3&gt;

&lt;p&gt;Shared database with isolation enforced below the application, until a customer contractually requires physical separation. The isolation layer matters far more than the physical layout.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I add multi-tenancy?
&lt;/h3&gt;

&lt;p&gt;Before the second customer organisation logs in. Retrofitting isolation means auditing every query already written.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is row-level security enough for multi-tenant SaaS?
&lt;/h3&gt;

&lt;p&gt;It is a strong row boundary and the right default on Postgres. It does not cover which &lt;em&gt;columns&lt;/em&gt; a caller may see or compute with, and it is opt-in per table.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop a tenant admin escalating their own permissions?
&lt;/h3&gt;

&lt;p&gt;Enforce a ceiling on role creation: a created role cannot exceed its creator's permissions or shadow a builtin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a database per tenant?
&lt;/h3&gt;

&lt;h2&gt;
  
  
  Almost certainly not yet. Wait for a contractual requirement or a measured problem.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run decision 2 against your own codebase this afternoon.&lt;/strong&gt; Find every place the tenant identity is established and confirm none of them reads it from a request field the caller controls. That is a one-hour audit and it is the highest-value item on this list.&lt;/p&gt;

&lt;p&gt;Then decision 5: pick a field some role cannot see, and try to sort by it. If the rows come back in the right order, you have found the next fortnight's work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How Supero answers decisions 3 and 5 is &lt;a href="https://docs.supero.dev/articles/field-level-permissions-order-by" rel="noopener noreferrer"&gt;the field-permission piece&lt;/a&gt;. What you can take with you if you ever leave is &lt;a href="https://docs.supero.dev/articles/what-you-walk-away-with" rel="noopener noreferrer"&gt;a separate page&lt;/a&gt;. Both are kept off this one so this one stays useful. Applied to specific tools: Bubble · Retool · Supabase · Lovable, Bolt and v0. Building these for clients: &lt;a href="https://docs.supero.dev/articles/freelancer-agency-playbook" rel="noopener noreferrer"&gt;what to charge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>saas</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
