<?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: Srikanth Reddy Kasa</title>
    <description>The latest articles on DEV Community by Srikanth Reddy Kasa (@srikanth-supero).</description>
    <link>https://dev.to/srikanth-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%2Fuser%2Fprofile_image%2F4112656%2Fe06bb515-cc1e-4add-bbba-3f0994081968.png</url>
      <title>DEV Community: Srikanth Reddy Kasa</title>
      <link>https://dev.to/srikanth-supero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/srikanth-supero"/>
    <language>en</language>
    <item>
      <title>Wholesale marketplace app Atelier asks for net terms before a card</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Wed, 14 Oct 2026 14:30:28 +0000</pubDate>
      <link>https://dev.to/srikanth-supero/wholesale-marketplace-app-atelier-asks-for-net-terms-before-a-card-79g</link>
      <guid>https://dev.to/srikanth-supero/wholesale-marketplace-app-atelier-asks-for-net-terms-before-a-card-79g</guid>
      <description>&lt;p&gt;&lt;em&gt;Prepaid, Net 15, Net 30 or Net 60. This open-source wholesale marketplace app is the one reference app of the 19 with net payment terms.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The last decision a buyer makes at &lt;a href="https://atelier.supero.live" rel="noopener noreferrer"&gt;Atelier&lt;/a&gt;'s checkout is when to pay. The options come from a single list near the top of &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketplace/atelier/schemas.py" rel="noopener noreferrer"&gt;&lt;code&gt;schemas.py&lt;/code&gt;&lt;/a&gt;:&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;NET_TERMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Prepaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net 15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net 30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net 60&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That list is the enum, the set of allowed values, behind &lt;code&gt;payment_terms&lt;/code&gt; on the order and &lt;code&gt;net_terms&lt;/code&gt; on the buyer's profile. We searched the other 18 apps for net terms and found none. Five others enable the same &lt;code&gt;stripe_checkout&lt;/code&gt; service. None of them offers terms. Atelier's code is in the &lt;a href="https://github.com/supero-platform/supero-apps/tree/main/apps/marketplace/atelier" rel="noopener noreferrer"&gt;atelier folder&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One field, two routes through checkout
&lt;/h2&gt;

&lt;p&gt;The checkout form has a terms dropdown, defaulting to Net 30. The submit button's label follows it: "Place order on Net 30" or "Pay with Stripe". &lt;code&gt;placeOrder&lt;/code&gt; in &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketplace/atelier/ui/app.js" rel="noopener noreferrer"&gt;&lt;code&gt;ui/app.js&lt;/code&gt;&lt;/a&gt; creates the order as &lt;code&gt;pending&lt;/code&gt; and &lt;code&gt;unpaid&lt;/code&gt;, then writes its lines. After that the two routes part.&lt;/p&gt;

&lt;p&gt;On net terms the order is updated to &lt;code&gt;confirmed&lt;/code&gt; and stays &lt;code&gt;unpaid&lt;/code&gt;. No card is asked for. The operator's dashboard counts unpaid orders under "On terms (unpaid)", and each order has a Mark paid button for when the money arrives.&lt;/p&gt;

&lt;p&gt;On Prepaid the code on &lt;code&gt;main&lt;/code&gt; calls &lt;code&gt;services.stripe.checkout&lt;/code&gt; with the order total and redirects to the hosted checkout URL, and since the success URL carries Stripe's session id, the app can call &lt;code&gt;retrieveCheckoutSession&lt;/code&gt; with it when the buyer comes back and write &lt;code&gt;pay_state: 'paid'&lt;/code&gt; only if Stripe itself reports the session as paid or complete. A failed checkout call leaves the order unpaid, with a message asking the buyer to try again.&lt;/p&gt;

&lt;p&gt;One more branch matters if you run a clone. When the Stripe service is absent from the environment, the code marks the order paid with &lt;code&gt;payment_provider&lt;/code&gt; set to &lt;code&gt;'simulated'&lt;/code&gt;, and the confirmation page says so in a banner.&lt;/p&gt;

&lt;h2&gt;
  
  
  An order that spans brands
&lt;/h2&gt;

&lt;p&gt;The cart lives in the browser, in &lt;code&gt;localStorage&lt;/code&gt; under one key. Each cart line remembers its &lt;code&gt;brand_name&lt;/code&gt;, and the cart page groups lines under a heading per brand.&lt;/p&gt;

&lt;p&gt;At checkout they become one order.&lt;/p&gt;

&lt;p&gt;The order schema carries the counts a multi-brand order needs, next to the two fields that record how it was paid:&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brand_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pay_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refunded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment_provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lines are a separate object. &lt;code&gt;OrderItem&lt;/code&gt; is declared with &lt;code&gt;"parent_type": "order"&lt;/code&gt;, so each line is a child of its order, and the front end reads them back with &lt;code&gt;client.getScopedList('order', o.uuid, 'order_item')&lt;/code&gt;. &lt;code&gt;Product&lt;/code&gt; is linked to its brand through a &lt;code&gt;references&lt;/code&gt; block with a back-reference named &lt;code&gt;products&lt;/code&gt;. It also keeps &lt;code&gt;brand_name&lt;/code&gt; as a plain string, and a product card needs no second lookup. &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketplace/atelier/setup.py" rel="noopener noreferrer"&gt;&lt;code&gt;setup.py&lt;/code&gt;&lt;/a&gt; seeds enough to browse. We counted 13 brands and 59 products, one buyer profile on Net 30, and two orders. Order ATL-100412 is delivered and paid at $168.50. Order ATL-100488 is confirmed and unpaid at $256.00. Each spans three brands.&lt;/p&gt;

&lt;h2&gt;
  
  
  In this wholesale marketplace, brands are rows and buyers own their orders
&lt;/h2&gt;

&lt;p&gt;Should each brand be a tenant, with its own separate data? A comment in &lt;code&gt;config.py&lt;/code&gt; answers that for this app, and the answer is no. There is a single tenant for the marketplace operator. Brands are ordinary records inside it. The comment gives the reason: discovery across brands is the whole point of a marketplace.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;Brand&lt;/code&gt; and &lt;code&gt;Product&lt;/code&gt; are the two public schemas and the storefront renders for a logged-out visitor. The buyer role, &lt;code&gt;tenant_user&lt;/code&gt;, may read both. Its other three rules cover the buyer profile, the order and the order line, and each one carries the same filter, &lt;code&gt;filter_field="owner_username"&lt;/code&gt; matched against &lt;code&gt;$user.name&lt;/code&gt;, so a buyer's order list contains their own orders and no one else's while the operator (&lt;code&gt;tenant_admin&lt;/code&gt;) sees every order on the marketplace. That is policy-based row scoping. The operator's rules are narrower than you might guess: delete is granted on brands and products, the two things the console has a delete button for, and on nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirmation mail and a buying assistant
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;setup.py&lt;/code&gt; the &lt;code&gt;order_confirmation&lt;/code&gt; workflow is bound to the event &lt;code&gt;@create:atelier:order&lt;/code&gt;, so placing an order is what sends the email. The recipient is mapped from &lt;code&gt;user.email&lt;/code&gt;, the signed-in buyer's account address. The address typed into the checkout form is stored on the order and is not where the mail goes.&lt;/p&gt;

&lt;p&gt;In the operator's console, Mark shipped and Mark delivered are plain updates to &lt;code&gt;order_state&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The storefront also has a curation panel. A buyer describes their shop, the code sends that description with the catalog to the &lt;code&gt;ai&lt;/code&gt; service, and the reply is matched back to real product rows by name. When the service is unavailable the panel shows the bestsellers and says why.&lt;/p&gt;

&lt;p&gt;All of it is 1,098 lines of React in &lt;code&gt;ui/app.js&lt;/code&gt;, the one front-end file you edit.&lt;/p&gt;

&lt;p&gt;Atelier is a demo of a wholesale ordering flow. The brands are seed data and nothing ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place a Net 30 order, then clone it
&lt;/h2&gt;

&lt;p&gt;One warning before you click. The demo scales to zero, so the first load can take ten to thirty seconds, and an idle one sometimes returns an error before it wakes. Try again if it does. The logins are shared and writable, so treat anything you create there as public.&lt;/p&gt;

&lt;p&gt;The login page at &lt;a href="https://atelier.supero.live" rel="noopener noreferrer"&gt;atelier.supero.live&lt;/a&gt; lists a buyer and an operator account. Sign in as the buyer, add products from two brands, and check out on Net 30. Then sign in as the operator and find it in the console.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/supero-platform/supero-apps/tree/main/apps/marketplace/atelier" rel="noopener noreferrer"&gt;source folder&lt;/a&gt; runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/supero-platform/supero-apps
&lt;span class="nb"&gt;cd &lt;/span&gt;supero-apps/apps/marketplace/atelier
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env    &lt;span class="c"&gt;# set SUPERO_DOMAIN and SUPERO_PASSWORD&lt;/span&gt;
./run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no account to create first. The storefront is running in about two minutes on &lt;code&gt;http://localhost:5661&lt;/code&gt;. Net-terms orders need nothing more, and the Prepaid route depends on the Stripe service. Replace the 13 brands in &lt;code&gt;BRANDS&lt;/code&gt; with your own catalogue and open a project at &lt;a href="https://www.supero.dev" rel="noopener noreferrer"&gt;supero.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: Supero is ours. Atelier is one of 19 reference apps we publish, and the platform it runs on is our hosted product.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>We gave an AI assistant one paragraph and our backend. It built a working app in 16 minutes, on the second try</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:06:19 +0000</pubDate>
      <link>https://dev.to/supero/we-gave-an-ai-assistant-one-paragraph-and-our-backend-it-built-a-working-app-in-16-minutes-on-the-4l59</link>
      <guid>https://dev.to/supero/we-gave-an-ai-assistant-one-paragraph-and-our-backend-it-built-a-working-app-in-16-minutes-on-the-4l59</guid>
      <description>&lt;p&gt;Last week we tried something we had been telling people was possible. We connected an AI coding assistant to our platform, gave it one paragraph describing a book club app, and stepped back.&lt;/p&gt;

&lt;p&gt;This is what happened, including the parts that did not work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Supero is a hosted backend for web apps. It provides the parts every app needs and few people enjoy writing: sign-in and access rules, with a database and an admin panel behind them. It also has an MCP server, which is how an assistant such as Claude Code or Cursor can talk to it directly.&lt;/p&gt;

&lt;p&gt;The brief was one paragraph: members propose books and vote on next month's read, then RSVP to the monthly meeting. An organiser schedules the meetings. A member can change only their own vote.&lt;/p&gt;

&lt;p&gt;We used Claude Code with a key that can build into one project and nothing else. Nothing was installed on the machine, and we wrote no code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the assistant did&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It made 46 tool calls. It began by asking the platform who it was and which project it was allowed to build into, then read the build spec, which the platform serves live so that it always matches what the platform will accept that day. It turned the paragraph into a plan of records and screens, with a role for each kind of user. It fetched a reference app to copy patterns from. Then it wrote seven files and asked the platform to validate them. When they passed, it published a version and deployed it.&lt;/p&gt;

&lt;p&gt;Validation and publishing both passed first time, in one to three seconds each. Nine minutes after the first call there was a URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then we looked at the app&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The deploy status said "running". The built-in test said "healthy". The home page said "No book selected for this month yet."&lt;/p&gt;

&lt;p&gt;That was wrong. The starter data included a selected book. We asked the assistant to read the logs, and there it was: 8 of the app's 27 starter records had been refused by our own API, each with a permission error, while the records next to them went through. The setup step had printed a warning box saying the app was incomplete and should not be treated as a successful deploy. Then it exited normally, and every tool reported success.&lt;/p&gt;

&lt;p&gt;A second deploy filled the gaps. Sixteen minutes after the first call we had an app we had clicked through, screen by screen: voting with a live tally, RSVPs that stuck, data that survived a reload.&lt;/p&gt;

&lt;p&gt;We also found that the app's one automation never ran. The spec told the assistant to include a workflow. The deploy could not register it, said so in a log line, and carried on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we got wrong, plainly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two faults, both ours, neither the assistant's.&lt;/p&gt;

&lt;p&gt;First, a cache. When an app's access rules were written, one of our servers kept answering from the old rules for up to five minutes, so any request that happened to land there was refused while the request beside it, sent a moment later to a different server, went straight through. That is why the failures looked random.&lt;/p&gt;

&lt;p&gt;Second, the workflow registration used the wrong identity inside the deployed app, one that is deliberately not allowed to touch service settings.&lt;/p&gt;

&lt;p&gt;Under both sat a third problem, and it is the one worth remembering, because it is the reason two real faults could sit in plain sight behind a green status: every success signal we had checked that the app was up, and not one of them checked that what the app declared had actually been created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we fixed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both faults were fixed within a day. We then wiped the test project and deployed the same app twice more. All 27 starter records were created both times. The workflow ran about five seconds after a member sent an RSVP. Deploys took about two and a half minutes.&lt;/p&gt;

&lt;p&gt;Two things are still not right, and we are saying so. One of the checking tools still prints an out-of-date warning about workflows. And the status tool still reports only that the app started, so for now you should open the app and look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the assistant got right and wrong&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right: the access rules. A member can change their own vote and nobody else's. It stored each vote as its own record so the tally needs no extra lookups, which we had not asked for.&lt;/p&gt;

&lt;p&gt;Wrong: voting never closes, though the brief implied rounds. A member cannot withdraw a vote, only change it. The first version greeted the user as "member" instead of by name.&lt;/p&gt;

&lt;p&gt;None of that is hidden. The app is in a public repo with its source and a list of seven things it does not do yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we are publishing this&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because that list is the point. We have opened a repo for people who want to build this way. It has a step-by-step guide to a first app and 31 project briefs across 16 areas. It also has the book club app, with its known gaps listed and five of them open as issues. Within a day of opening them, someone we have never met claimed one.&lt;/p&gt;

&lt;p&gt;You do not need to ask before building a brief, and several people can build the same one. The free plan covers three projects and asks for no card, though what it gives you is preview deployments that expire after thirty minutes. A URL that stays up is a paid plan. The apps you build are yours, under the MIT licence. The platform underneath is a hosted service, not open source. We would rather say that here than have you find out later.&lt;/p&gt;

&lt;p&gt;Three things we would tell another team trying this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the logs of your first real run yourself. Our dashboards were green the whole time.&lt;/li&gt;
&lt;li&gt;Publish the gaps. A list of what does not work is an invitation; a polished demo is not.&lt;/li&gt;
&lt;li&gt;Count one run as one run. Sixteen minutes is what happened to us once, after one redeploy. It is not a benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/supero-platform/supero-mcp" rel="noopener noreferrer"&gt;https://github.com/supero-platform/supero-mcp&lt;/a&gt;&lt;br&gt;
First app, step by step: &lt;a href="https://github.com/supero-platform/supero-mcp/blob/main/docs/first-app.md" rel="noopener noreferrer"&gt;https://github.com/supero-platform/supero-mcp/blob/main/docs/first-app.md&lt;/a&gt;&lt;br&gt;
The book club app and its gaps: &lt;a href="https://github.com/supero-platform/supero-mcp/tree/main/apps/community/book-club" rel="noopener noreferrer"&gt;https://github.com/supero-platform/supero-mcp/tree/main/apps/community/book-club&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Did the post go out? Amplify, a social media management app, records it</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Mon, 12 Oct 2026 11:41:37 +0000</pubDate>
      <link>https://dev.to/supero/did-the-post-go-out-amplify-a-social-media-management-app-records-it-3002</link>
      <guid>https://dev.to/supero/did-the-post-go-out-amplify-a-social-media-management-app-records-it-3002</guid>
      <description>&lt;p&gt;&lt;em&gt;This open-source social media management app enables eight services in &lt;code&gt;config.py&lt;/code&gt;, four of them social networks, and keeps three fields on the post that stop the app from overstating what it did.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Press Publish on a post in &lt;a href="https://amplify.supero.live" rel="noopener noreferrer"&gt;Amplify&lt;/a&gt; and the app stores what happened next. The evidence is on the row. These are the last three attributes of &lt;code&gt;Post&lt;/code&gt; in &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketing/amplify/schemas.py" rel="noopener noreferrer"&gt;&lt;code&gt;schemas.py&lt;/code&gt;&lt;/a&gt;:&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="c1"&gt;# Real-posting result: did it actually post to the channel, or fall back to simulated?
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;live&lt;/span&gt;&lt;span class="sh"&gt;"&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider_post_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publish_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We searched the &lt;code&gt;config.py&lt;/code&gt; and &lt;code&gt;ui/app.js&lt;/code&gt; of all 19 apps for the four posting services (&lt;code&gt;instagram&lt;/code&gt;, &lt;code&gt;linkedin&lt;/code&gt;, &lt;code&gt;x_social&lt;/code&gt;, &lt;code&gt;youtube&lt;/code&gt;). Only this app lists them. The hosted demo simulates posting, so a Publish there does not reach a real social network. The source is the &lt;a href="https://github.com/supero-platform/supero-apps/tree/main/apps/marketing/amplify" rel="noopener noreferrer"&gt;amplify folder&lt;/a&gt; of the supero-apps repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  A social media management app where four networks post and four are tracked
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;platform&lt;/code&gt; enum has eight values. &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketing/amplify/ui/app.js" rel="noopener noreferrer"&gt;&lt;code&gt;ui/app.js&lt;/code&gt;&lt;/a&gt; names four of them in a constant, &lt;code&gt;REAL_CHANNELS&lt;/code&gt;, and a function called &lt;code&gt;realPost&lt;/code&gt; maps each of those to an integration call. Instagram needs an image. YouTube needs a public video URL, held in the &lt;code&gt;video_url&lt;/code&gt; attribute. For Facebook, TikTok, WhatsApp and Google Ads the function returns &lt;code&gt;null&lt;/code&gt;, and the composer labels those channels "tracked only".&lt;/p&gt;

&lt;p&gt;What happens next is the part to copy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;publish&lt;/code&gt; tries the real call first. If the provider answers with an id or a success status, the row is saved with &lt;code&gt;delivery: "live"&lt;/code&gt; and that id in &lt;code&gt;provider_post_id&lt;/code&gt;, and if the call throws, or comes back without a confirmation, or &lt;code&gt;realPost&lt;/code&gt; has no call for that network, the row is saved with &lt;code&gt;delivery: "simulated"&lt;/code&gt; and whatever error message there was goes into &lt;code&gt;publish_error&lt;/code&gt;. The pop-up message differs too. A live post gets "Posted to LinkedIn for real". A simulated one is announced as simulated, with a prompt to connect the channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  An operator and their customers in one tenant
&lt;/h2&gt;

&lt;p&gt;Amplify models a SaaS business, so it has two audiences who never share a screen. The customer is a marketer. The operator is the company running Amplify. Both live in one tenant, a single shared set of records: a customer company is a row in &lt;code&gt;Workspace&lt;/code&gt;, and &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/apps/marketing/amplify/setup.py" rel="noopener noreferrer"&gt;&lt;code&gt;setup.py&lt;/code&gt;&lt;/a&gt; scopes the marketer (&lt;code&gt;tenant_user&lt;/code&gt;) to rows they own.&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="nc"&gt;PolicyRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;can_read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;filter_field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter_match&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$user.name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same filter sits on the three working objects (&lt;code&gt;channel&lt;/code&gt;, &lt;code&gt;campaign&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;) with create and update added. This is policy-based row scoping, and the &lt;code&gt;workspace&lt;/code&gt; rule is read-only for the marketer. A customer can see their plan and billing state and cannot write to either.&lt;/p&gt;

&lt;p&gt;The operator signs in as &lt;code&gt;tenant_admin&lt;/code&gt; and gets a different console, with tabs for billing and for every workspace and campaign on the platform. Its MRR (monthly recurring revenue) tile sums &lt;code&gt;mrr&lt;/code&gt; over workspaces whose state is &lt;code&gt;active&lt;/code&gt; or &lt;code&gt;past_due&lt;/code&gt;. We counted the seed: 4 plans and 6 workspaces, then 10 channels, 8 campaigns, 16 posts. On those rows the tile comes to $456 from four counted workspaces, and the marketer login owns 9 of the 16 posts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Plan&lt;/code&gt; is the one public schema, so the pricing page renders for a logged-out visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why workflows comes last in the list
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;config.py&lt;/code&gt; enables eight services, and a comment above the list explains their order:&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;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slack&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instagram&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;linkedin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x_social&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;youtube&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment's reason is that event bindings are wired up when the &lt;code&gt;workflows&lt;/code&gt; service registers, so every other service has to be present first.&lt;/p&gt;

&lt;p&gt;There are three workflows and two of them have no button. &lt;code&gt;campaign_launch&lt;/code&gt; is bound to &lt;code&gt;@create:amplify:campaign&lt;/code&gt; and sends an email plus a message to the &lt;code&gt;#campaigns&lt;/code&gt; Slack channel. &lt;code&gt;workspace_welcome&lt;/code&gt; is bound to &lt;code&gt;@create:amplify:workspace&lt;/code&gt;. The third, &lt;code&gt;post_publish&lt;/code&gt;, is a saga that marks the post published, emails the owner and then writes the metrics, and its first step carries a &lt;code&gt;compensate&lt;/code&gt; block that sets &lt;code&gt;post_state&lt;/code&gt; back to &lt;code&gt;scheduled&lt;/code&gt; if a later step fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Captions with a fallback you can see
&lt;/h2&gt;

&lt;p&gt;The composer has an AI panel. You type a brief and pick platforms. It calls the &lt;code&gt;ai&lt;/code&gt; service once per platform with a short style guide for each, asking for under 280 characters on X and for a hook line with a single call to action on LinkedIn, and when the service is unavailable or returns no text, &lt;code&gt;generateCaption&lt;/code&gt; falls back to a template written for that platform.&lt;/p&gt;

&lt;p&gt;Each draft card is tagged "AI" or "template", from a flag &lt;code&gt;generateCaption&lt;/code&gt; returns with the text.&lt;/p&gt;

&lt;p&gt;The front end is a single file, &lt;code&gt;ui/app.js&lt;/code&gt;, 887 lines in the repo. You edit it and refresh the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the marketer, then the operator
&lt;/h2&gt;

&lt;p&gt;One warning before you click. The demo scales to zero, so the first load can take twenty or thirty seconds, and an idle one sometimes returns an error before it wakes. Try again if it does. The logins below are shared and writable, so treat anything you create there as public.&lt;/p&gt;

&lt;p&gt;Two demo accounts are listed on the login page at &lt;a href="https://amplify.supero.live" rel="noopener noreferrer"&gt;amplify.supero.live&lt;/a&gt;. Sign in as the marketer and open the Composer and Posts tabs. In the seed, two of the marketer's 9 posts are scheduled and two are drafts. Then sign in as the operator, whose console lists every workspace. The seed has six.&lt;/p&gt;

&lt;p&gt;To run it from the &lt;a href="https://github.com/supero-platform/supero-apps/tree/main/apps/marketing/amplify" rel="noopener noreferrer"&gt;source folder&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/supero-platform/supero-apps
&lt;span class="nb"&gt;cd &lt;/span&gt;supero-apps/apps/marketing/amplify
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env    &lt;span class="c"&gt;# set SUPERO_DOMAIN and SUPERO_PASSWORD&lt;/span&gt;
./run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs no account and is running in about two minutes on &lt;code&gt;http://localhost:5672&lt;/code&gt;. Everything in the folder is MIT-licensed, while the platform underneath is hosted and its source is not open. Until a network's credentials are configured on your own domain, every publish will be recorded as &lt;code&gt;simulated&lt;/code&gt; on the row. To build a scheduler around your own channels, the starting point is &lt;a href="https://www.supero.dev" rel="noopener noreferrer"&gt;supero.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: Supero is ours. Amplify is one of 19 reference apps we publish, and the platform it runs on is our hosted product.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <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>
    <item>
      <title>Here's my app's access control, the passwords, and a curl command. Go break it</title>
      <dc:creator>Srikanth Reddy Kasa</dc:creator>
      <pubDate>Sun, 06 Sep 2026 20:39:37 +0000</pubDate>
      <link>https://dev.to/srikanth-supero/heres-my-apps-access-control-the-passwords-and-a-curl-command-go-break-it-2d50</link>
      <guid>https://dev.to/srikanth-supero/heres-my-apps-access-control-the-passwords-and-a-curl-command-go-break-it-2d50</guid>
      <description>&lt;p&gt;I moved my app's authorization below the application code. Here is the live demo, the passwords, and the curl commands to try to break it.&lt;/p&gt;

&lt;p&gt;Every B2B app I built before this one put authorization &lt;em&gt;in&lt;/em&gt; the application code. A decorator here, an &lt;code&gt;if user.role == 'admin'&lt;/code&gt; there, a query that remembers to add &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;. It works, right up until one endpoint forgets, and then it is a data leak with a CVE number.&lt;/p&gt;

&lt;p&gt;The thing that bothers me is that this is &lt;em&gt;re-litigated in every codebase&lt;/em&gt;. The rule "policyholders must never see the fraud score" is a business fact. It ends up encoded across a serializer, three endpoints and a React component, and nothing structurally prevents the fourth endpoint from getting it wrong.&lt;/p&gt;

&lt;p&gt;So I built the other version: the rules are a declaration, and the server applies them in front of the database, before application code runs.&lt;/p&gt;

&lt;p&gt;That is an easy claim to make and a cheap one to fake. So here is a live app, the passwords, and the commands to check.&lt;/p&gt;




&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sentinel.supero.live" rel="noopener noreferrer"&gt;Sentinel&lt;/a&gt; is an insurance claims system — policies, claims intake, adjuster review with fraud scoring, documents, and separate portals for policyholders and staff.&lt;/p&gt;

&lt;p&gt;It runs &lt;strong&gt;two insurers on one deployment&lt;/strong&gt;: Northwind Mutual and Cascade Assurance. Four logins, all with the password &lt;code&gt;Password123!&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenant&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Northwind Mutual&lt;/td&gt;
&lt;td&gt;Policyholder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;member@sentinel.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Northwind Mutual&lt;/td&gt;
&lt;td&gt;Claims team&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claims@sentinel.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cascade Assurance&lt;/td&gt;
&lt;td&gt;Policyholder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;member@cascade.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cascade Assurance&lt;/td&gt;
&lt;td&gt;Claims team&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claims@cascade.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The data is fictional and gets reset. Please don't put anything real in it.&lt;/p&gt;

&lt;p&gt;There are two separate things to try to break: &lt;strong&gt;fields&lt;/strong&gt; (can a policyholder get the fraud score?) and &lt;strong&gt;tenants&lt;/strong&gt; (can Northwind get Cascade's book of business?).&lt;/p&gt;




&lt;h2&gt;
  
  
  Test 1: the field the client never receives
&lt;/h2&gt;

&lt;p&gt;Adjusters see &lt;code&gt;fraud_score&lt;/code&gt; and &lt;code&gt;internal_notes&lt;/code&gt; on every claim. Policyholders must never see either. Log in as the policyholder and read the claims:&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="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.supero.dev

login&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/auth/login"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;domain_name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;supero-apps&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;project&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;sentinel&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
         &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Password123!&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;login member@sentinel.insure | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .auth.access_token&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/crud/supero-apps/sentinel:claim"&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;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="s1"&gt;'{claims: .result_count,
       fraud_score:    [.results[] | has("fraud_score")]    | any,
       internal_notes: [.results[] | has("internal_notes")] | any}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"claims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now change one word — &lt;code&gt;member&lt;/code&gt; to &lt;code&gt;claims&lt;/code&gt; — and run it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"claims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same endpoint, same query, no filter parameter. The fields are &lt;strong&gt;not in the JSON&lt;/strong&gt; for the policyholder. Not &lt;code&gt;display: none&lt;/code&gt;, not dropped by the frontend — never sent. Open devtools on the live demo and they are not in the network tab either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: the tenant boundary
&lt;/h2&gt;

&lt;p&gt;Both claims-team accounts are &lt;code&gt;tenant_admin&lt;/code&gt; — the most privileged role in the app. Read claims as each of them:&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;EMAIL &lt;span class="k"&gt;in &lt;/span&gt;claims@sentinel.insure claims@cascade.insure&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;login &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .auth.access_token&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; "&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/crud/supero-apps/sentinel:claim"&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;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'[.results[].claim_number] | sort | .[0:3]'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;claims@sentinel.insure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CLM-202047"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-203980"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-204120"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;claims@cascade.insure&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88214"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88301"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88355"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Northwind numbers its claims &lt;code&gt;CLM-…&lt;/code&gt;, Cascade uses &lt;code&gt;CLM-CA-…&lt;/code&gt; — deliberately, so a leak would be obvious on sight rather than needing a UUID comparison. Neither admin can reach the other's rows. There is no &lt;code&gt;tenant_id&lt;/code&gt; in the request for anyone to tamper with; the tenant is bound to the session at login.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the rules come from
&lt;/h2&gt;

&lt;p&gt;The login response includes the policy the server issued for that session. The client does not choose it, and cannot alter it — it is shown to the client so the UI knows what to render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;login member@sentinel.insure | jq &lt;span class="s1"&gt;'.policy.entities["sentinel:claim"]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sentinel:claim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_create"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_update"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter_field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c1"&gt;// row scope&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter_match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$user.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hidden_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// field scope&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"readonly_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created_by"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant_uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default_access&lt;/code&gt; for this role is &lt;code&gt;none&lt;/code&gt;; every entity the role can touch is listed explicitly. The claims-team policy is the same document without the &lt;code&gt;filter_field&lt;/code&gt; and &lt;code&gt;hidden_fields&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;The part I find genuinely useful: look at the schema that defines a claim and note what is &lt;em&gt;not&lt;/em&gt; there. &lt;code&gt;fraud_score&lt;/code&gt; is an ordinary integer attribute. Nothing in the data model marks it as secret. The sensitivity is declared separately, in the access policy, and applied by the server. Which means the answer to "who can see the fraud score?" is one file, not a grep across the codebase.&lt;/p&gt;




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

&lt;p&gt;If you go read the app's own source, you will find role checks in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAdmin&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🛡 Claims console&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am not going to pretend those don't exist, because you would find them in a minute and then disbelieve the rest. They decide &lt;strong&gt;what the UI draws&lt;/strong&gt;. They are not what protects the data, and that is the whole point: if I deleted every one of them and shipped the claims console to policyholders, the console would render with the fraud score column empty, because the server still won't send the field. The UI check is a convenience. The enforcement is underneath it.&lt;/p&gt;

&lt;p&gt;That is the difference I was after. In the version I have written five times before, that &lt;code&gt;isAdmin&lt;/code&gt; check &lt;em&gt;was&lt;/em&gt; the security boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;Being straight about the edges, because you'd find them anyway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No field-level encryption here.&lt;/strong&gt; The platform supports encrypted fields; this app doesn't use them, so don't read the above as evidence about encryption at rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo logins are shared and public&lt;/strong&gt;, and they can write. The data is fictional and reset periodically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is one app's configuration, not an audit.&lt;/strong&gt; I have shown you the two axes I claimed. I have not shown you that every endpoint in the platform is correct, and you should not take a blog post as proof of that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI code is hand-written.&lt;/strong&gt; The schemas, the API, the access enforcement and the admin console are generated from declarations; the custom screens in this app are a file I wrote by hand. I have seen "N bytes in, M bytes out" claims made about codebases like this one and I'm not going to make one.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;The live demo is at &lt;strong&gt;&lt;a href="https://sentinel.supero.live" rel="noopener noreferrer"&gt;sentinel.supero.live&lt;/a&gt;&lt;/strong&gt; — sign in as a policyholder and as the claims team on the same claim and watch the payload change.&lt;/p&gt;

&lt;p&gt;If you'd rather read the schemas and the access policy than run curl, the app is on GitHub under MIT: &lt;strong&gt;&lt;a href="https://github.com/supero-platform/supero-apps" rel="noopener noreferrer"&gt;github.com/supero-platform/supero-apps&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;apps/insurance/sentinel&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Want this on your own data? The repo quickstart clones any of the 19 apps onto a domain you own in about five minutes, no card needed: &lt;a href="https://github.com/supero-platform/supero-apps/blob/main/docs/quickstart.md" rel="noopener noreferrer"&gt;docs/quickstart.md&lt;/a&gt;. Or start from scratch: &lt;a href="https://app.supero.dev/register?utm_source=devto&amp;amp;utm_campaign=post1" rel="noopener noreferrer"&gt;app.supero.dev/register&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm curious where people land on this. The objection I expect — and half agree with — is that moving authorization into a declarative layer trades one failure mode for another: you can't grep for it, and a misconfigured policy is as bad as a missing &lt;code&gt;if&lt;/code&gt;. If you've run something like this in production, I'd like to hear which way that went.&lt;/p&gt;

&lt;p&gt;If you enforce authorization below the app layer, what broke first — and if you don't, what's stopped you?&lt;/p&gt;

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