<?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: AuditAI</title>
    <description>The latest articles on DEV Community by AuditAI (@auditai).</description>
    <link>https://dev.to/auditai</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%2F4122334%2Fb017c2b5-5ecd-4b9a-9eab-6e257940b38a.png</url>
      <title>DEV Community: AuditAI</title>
      <link>https://dev.to/auditai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/auditai"/>
    <language>en</language>
    <item>
      <title>We applied 243 generated Supabase security migrations to real schemas. Here is what broke.</title>
      <dc:creator>AuditAI</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:29:58 +0000</pubDate>
      <link>https://dev.to/auditai/we-applied-243-generated-supabase-security-migrations-to-real-schemas-here-is-what-broke-4gah</link>
      <guid>https://dev.to/auditai/we-applied-243-generated-supabase-security-migrations-to-real-schemas-here-is-what-broke-4gah</guid>
      <description>&lt;p&gt;Our free check reads a snapshot of a Supabase database and, where the fix follows from the schema, writes the migration that closes the hole, plus the lines that undo it. Until this week we had only tested those migrations with unit tests. So we ran them against real schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;155 public Next.js + Supabase repositories from our earlier precision samples.&lt;/li&gt;
&lt;li&gt;A database built the way a new Supabase project has it: the &lt;code&gt;supabase/postgres&lt;/code&gt; image, then GoTrue's own migrations (&lt;code&gt;auth migrate&lt;/code&gt;) and one start of &lt;code&gt;storage-api&lt;/code&gt;. Without those you have no &lt;code&gt;auth.jwt()&lt;/code&gt;, no &lt;code&gt;storage.objects&lt;/code&gt;, and, most important, no Supabase default privileges, so every new table looks private when it is not.&lt;/li&gt;
&lt;li&gt;Each repository's &lt;code&gt;supabase/migrations&lt;/code&gt; applied in order as &lt;code&gt;postgres&lt;/code&gt;, the way the CLI does. 59 of 133 rebuilt cleanly; most of the rest reference a table no migration creates.&lt;/li&gt;
&lt;li&gt;Everything ran on a Docker network with no route out, since migrations can call &lt;code&gt;pg_net&lt;/code&gt; or &lt;code&gt;http&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For every finding with a migration, on its own copy of the database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;as the stranger the finding names (anon, or any signed-in user), try what it claims: read the table, add a row, call the function;&lt;/li&gt;
&lt;li&gt;apply the fix as &lt;code&gt;postgres&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;snapshot again and re-run the check: the finding must be gone and nothing new may appear;&lt;/li&gt;
&lt;li&gt;try again as the stranger;&lt;/li&gt;
&lt;li&gt;apply the rollback and compare the snapshot with the first one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Numbers
&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Findings with a migration&lt;/td&gt;
&lt;td&gt;243 (on 34 databases)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applied without error&lt;/td&gt;
&lt;td&gt;243&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gone on re-check, nothing new&lt;/td&gt;
&lt;td&gt;243&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback restored the same access&lt;/td&gt;
&lt;td&gt;243&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All fixes of a project in one file&lt;/td&gt;
&lt;td&gt;24 projects, 0 errors, 0 findings left&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before each fix, the stranger really could do what the finding said in 239 of 243 cases: 152 of 152 SECURITY DEFINER functions ran for anon or a signed-in user, 34 of 34 tables without RLS were readable and writable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A rollback handed out a right that was never there.&lt;/strong&gt; Our fix for an open SECURITY DEFINER function revokes EXECUTE from &lt;code&gt;public, anon, authenticated&lt;/code&gt; and grants it to &lt;code&gt;service_role&lt;/code&gt;. The rollback treated that last grant as a no-op, because Supabase's default privileges give &lt;code&gt;service_role&lt;/code&gt; EXECUTE on every new function. One project had revoked it on purpose. After "undo", the service role could run three functions it could not run before. The rollback now takes it back when the snapshot shows it was not held.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two cards, one line.&lt;/strong&gt; A function with two overloads produced two findings that quoted the same fact line. They now show the argument list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four headlines named the wrong stranger.&lt;/strong&gt; They said "anyone can change every row" for an &lt;code&gt;UPDATE&lt;/code&gt; policy open to &lt;code&gt;anon&lt;/code&gt;. Through the Data API, anon could change nothing: Supabase's &lt;code&gt;authenticator&lt;/code&gt; role preloads &lt;code&gt;safeupdate&lt;/code&gt;, so an UPDATE needs a WHERE, and a WHERE that reads a column makes Postgres apply the SELECT policies too. These tables had no read policy for anon, so the update matched no rows. In three of the four, signed-in users could read the table, so the true headline is "anyone who signs up can change every row". The rule now works out who can change which rows from what they can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;Re-checked is not verified. We did not run anyone's app, and a &lt;code&gt;security invoker&lt;/code&gt; fix can break an app that relied on the owner's rights. That is what the rollback lines are for.&lt;/p&gt;

&lt;p&gt;The check is free at &lt;a href="https://auditai.sh/check?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=fix-loop" rel="noopener noreferrer"&gt;https://auditai.sh/check?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=fix-loop&lt;/a&gt;. It reads names and rules, never a row of your data.&lt;/p&gt;

</description>
      <category>security</category>
      <category>nextjs</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Three blind tests of our security scanner: 36%, 46%, 31%</title>
      <dc:creator>AuditAI</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:31:03 +0000</pubDate>
      <link>https://dev.to/auditai/three-blind-tests-of-our-security-scanner-36-46-31-1403</link>
      <guid>https://dev.to/auditai/three-blind-tests-of-our-security-scanner-36-46-31-1403</guid>
      <description>&lt;p&gt;Most security tools publish detection rates measured on benchmarks their own authors built. We wanted a number we could not quietly improve by tuning, so we measure Audit AI's scanner blind, on repositories it has never seen. We have run that test three times. This post is the protocol, all three results, and what they taught us.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scanner reports
&lt;/h2&gt;

&lt;p&gt;Audit AI reads a Next.js + Supabase codebase together with its SQL migrations and reports authorization bugs: a service-role query filtered only by an id from the URL, a table without row level security, a write policy open to &lt;code&gt;anon&lt;/code&gt;, a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function that never checks its caller. Every finding is a claim about code, so every finding can be checked by reading that code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol, committed before each sample
&lt;/h2&gt;

&lt;p&gt;Before choosing a single repository, we commit the protocol to our repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Selection.&lt;/strong&gt; Public Next.js + Supabase repositories from GitHub code search, taken in the order the search returns them, excluding copies and anything in a corpus we had already tuned the engine on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size.&lt;/strong&gt; 20 repositories and 100 findings, with at most 20 findings from one repository in the later samples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampling.&lt;/strong&gt; A seeded pseudo-random draw from all findings. The sample is reproducible, and nobody picks the interesting ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels.&lt;/strong&gt; A person reads each finding in the code and marks it &lt;code&gt;real&lt;/code&gt;, &lt;code&gt;false positive&lt;/code&gt; or &lt;code&gt;unsure&lt;/code&gt;. The rules for edge cases are written before labelling. Unsure findings stay out of the denominator.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Real / decided&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;95% interval&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First&lt;/td&gt;
&lt;td&gt;36 / 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;27–45%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second&lt;/td&gt;
&lt;td&gt;44 / 95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;46%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37–56%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third&lt;/td&gt;
&lt;td&gt;31 / 99&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;31%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23–41%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Intervals are Wilson intervals. The drop from the second sample to the third is statistically significant (p ≈ 0.03). The difference between the first and the third is within noise (p ≈ 0.48).&lt;/p&gt;

&lt;p&gt;Each build had fixes made after reading the previous sample's labels, and the new repositories still moved the number more than any of those fixes did. That is the main lesson: a precision figure measured on one set of repositories says little about the next set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the scanner was right, and where it was not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions without a caller check&lt;/strong&gt; were real in 12 of 16, 12 of 30 and 18 of 42 findings. In the second sample, twelve of the false positives were functions in one repository that expose public data on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write policies open to &lt;code&gt;anon&lt;/code&gt;&lt;/strong&gt; were real in 16 of the 20 findings across all three samples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-role object access without a tenant scope&lt;/strong&gt; was the weakest rule: real in 19 of 50, 2 of 18 and 1 of 22. Most false positives were apps where the tenant check lives in code or a sign-in the scanner does not follow.&lt;/li&gt;
&lt;li&gt;The third sample was concentrated: three repositories gave 60 of its 100 findings, with precision of 75%, 15% and 15%.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we do with it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A finding in Audit AI stays &lt;code&gt;likely&lt;/code&gt; until a sandbox reproduces it: two synthetic tenants, the real request, and a check that the owner's own access still works. Only then is it &lt;code&gt;confirmed&lt;/code&gt;, and a fix counts as verified only if the same attack fails afterwards.&lt;/li&gt;
&lt;li&gt;Every number is on a public page, including the one that went down: &lt;a href="https://auditai.sh/stats?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=blind-precision" rel="noopener noreferrer"&gt;https://auditai.sh/stats?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=blind-precision&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Any fix made after reading these labels makes them no longer blind. The next honest number needs new repositories again.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The scanner is open source under Apache-2.0: &lt;a href="https://github.com/audit0/auditai-scanner" rel="noopener noreferrer"&gt;https://github.com/audit0/auditai-scanner&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also paste a public repository at &lt;a href="https://auditai.sh/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=blind-precision" rel="noopener noreferrer"&gt;https://auditai.sh/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=blind-precision&lt;/a&gt; and press &lt;strong&gt;Prove it&lt;/strong&gt; on a finding. If it gets something wrong on your code, an issue with the smallest snippet that reproduces it is the most useful thing you can send us.&lt;/p&gt;

</description>
      <category>security</category>
      <category>nextjs</category>
      <category>supabase</category>
    </item>
    <item>
      <title>The bug every AI coding tool ships, and how to prove it is gone</title>
      <dc:creator>AuditAI</dc:creator>
      <pubDate>Sat, 12 Sep 2026 17:24:38 +0000</pubDate>
      <link>https://dev.to/auditai/the-bug-every-ai-coding-tool-ships-and-how-to-prove-it-is-gone-55pf</link>
      <guid>https://dev.to/auditai/the-bug-every-ai-coding-tool-ships-and-how-to-prove-it-is-gone-55pf</guid>
      <description>&lt;p&gt;The scanner is open source and the rules are readable before you trust a single finding: &lt;a href="https://github.com/audit0/auditai-scanner" rel="noopener noreferrer"&gt;github.com/audit0/auditai-scanner&lt;/a&gt;. This post is about the one bug it was built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;Ask any AI coding tool for an invoicing app and you will get something close to this route handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/invoices/[id]/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It passes review if the reviewer is reading for "does this return an invoice". The service-role key bypasses row level security, and the query filters on &lt;code&gt;id&lt;/code&gt; alone, so Alice can read Bob's invoice by changing a number in the URL.&lt;/p&gt;

&lt;p&gt;This is not an exotic bug. It is the default outcome of asking for a feature and not asking who is allowed to see it, and it shows up over and over in apps built with Lovable, Bolt, v0, Cursor and Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a warning is not enough
&lt;/h2&gt;

&lt;p&gt;A scanner that prints "possible authorization issue" at this line has told you almost nothing. You still have to open the file, work out whether the route is reachable, whether some middleware already blocks it, and whether the id is scoped somewhere you did not read. Most teams do that work once, find two false positives, and stop reading the tool's output.&lt;/p&gt;

&lt;p&gt;"The model says it fixed it" is worse. A model that both writes the fix and grades the fix is not evidence of anything.&lt;/p&gt;

&lt;p&gt;So we picked a harder bar. A finding stays &lt;code&gt;likely&lt;/code&gt; until something outside the model reproduces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reproduction looks like
&lt;/h2&gt;

&lt;p&gt;We copy the app into a sandbox with no internet access, seed two synthetic tenants, and send the same request twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;proof · AUDIT-001
GET /api/invoices/42 as Alice
✗ before fix: 200 OK
✓ after fix:  403 Forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice is not supposed to see invoice 42. Before the fix the endpoint hands it over. That is the evidence. It is also, conveniently, a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix and the regression test
&lt;/h2&gt;

&lt;p&gt;The fix is the smallest one that closes the path, not a refactor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
&lt;/span&gt;&lt;span class="gi"&gt;+import { createServerClient } from "@/lib/supabase/server"; export async function GET(_req: Request, { params }: { params: { id: string } }) {
&lt;/span&gt;&lt;span class="gd"&gt;-  const { data } = await supabase.from("invoices").select("*").eq("id", params.id).single();
&lt;/span&gt;&lt;span class="gi"&gt;+  const supabase = await createServerClient();
+  const { data, error } = await supabase.from("invoices").select("*").eq("id", params.id).single();
+  if (error || !data) return new Response("Forbidden", { status: 403 });
&lt;/span&gt;   return Response.json(data);
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request now runs as the signed-in user, so the table's row level security policy decides the answer instead of the route.&lt;/p&gt;

&lt;p&gt;The regression test is the reproduction, kept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;does not let one tenant read another tenant's invoice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/invoices/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bobInvoiceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aliceAuth&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It failed before the fix. It passes after. If someone reintroduces the service-role client next quarter, it fails again.&lt;/p&gt;

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

&lt;p&gt;Every result ends with a coverage line rather than a score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checked 12 routes · verified 2 · confirmed 1 · unverified 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unverified&lt;/code&gt; is not a failure to hide. It is the set of findings we could not reproduce, and we would rather show you the number than dress it up. Findings move from &lt;code&gt;candidate&lt;/code&gt; to &lt;code&gt;likely&lt;/code&gt; to &lt;code&gt;confirmed&lt;/code&gt; only on evidence, and a fix is &lt;code&gt;verified&lt;/code&gt; only when the regression test failed before it and passes after it, the existing suite still passes, and a deterministic rescan no longer sees the path.&lt;/p&gt;

&lt;p&gt;We measure precision on repositories we have never seen, and publish the number instead of claiming one.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx auditai-scan &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs locally, needs no account, and sends nothing anywhere. Source, rules and the eval fixtures are here: &lt;a href="https://github.com/audit0/auditai-scanner" rel="noopener noreferrer"&gt;github.com/audit0/auditai-scanner&lt;/a&gt;. The hosted product that reproduces, fixes and proves is at &lt;a href="https://auditai.sh?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;auditai.sh&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have a Next.js + Supabase app, scan it now at &lt;a href="https://auditai.sh:" rel="noopener noreferrer"&gt;https://auditai.sh:&lt;/a&gt; no sign-up, no waitlist. For a private repository or a fix proven on your own app, write to &lt;a href="mailto:hello@auditai.sh"&gt;hello@auditai.sh&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>nextjs</category>
      <category>supabase</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
