<?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>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>
