<?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: Andrew</title>
    <description>The latest articles on DEV Community by Andrew (@ad23dev).</description>
    <link>https://dev.to/ad23dev</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%2F4221201%2F7aa1f642-ca99-40d4-bbed-de6dba427c4a.png</url>
      <title>DEV Community: Andrew</title>
      <link>https://dev.to/ad23dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ad23dev"/>
    <language>en</language>
    <item>
      <title>I scanned 25 vibe-coded apps. The API keys were fine. The server wasn't.</title>
      <dc:creator>Andrew</dc:creator>
      <pubDate>Sun, 01 Nov 2026 21:15:25 +0000</pubDate>
      <link>https://dev.to/ad23dev/i-scanned-25-vibe-coded-apps-the-api-keys-were-fine-the-server-wasnt-1lc6</link>
      <guid>https://dev.to/ad23dev/i-scanned-25-vibe-coded-apps-the-api-keys-were-fine-the-server-wasnt-1lc6</guid>
      <description>&lt;p&gt;Everyone's favorite vibe-coding horror story is the API key pasted into the frontend. So I went looking for it.&lt;/p&gt;

&lt;p&gt;I took 25 public vibe-coded apps on GitHub, scanned every file and every line of their git history, and then read their server code by hand.&lt;/p&gt;

&lt;p&gt;The keys were fine. Not one leaked. What I found instead was a hidden key behind a door anyone could walk through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I did it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Finding the repos.&lt;/strong&gt; I used GitHub's search API with queries like &lt;code&gt;"lovable.dev/projects" in:readme stars:&amp;gt;=3&lt;/code&gt;, plus searches for Bolt and v0 projects. I kept repos that were active, had more than a template's worth of code, and clearly came from a builder tool: 19 Lovable, 3 Bolt, 2 v0. (Cursor and Replit don't leave a reliable public fingerprint, so none made this batch.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanner pass.&lt;/strong&gt; I ran secret, Supabase, Firebase and cost-trap detectors over every text file at HEAD (2,269 files, skipping node_modules, lockfiles, build output and binaries), then over every added line in the full history (&lt;code&gt;git log -p --all&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual pass.&lt;/strong&gt; I read the server code myself: &lt;code&gt;supabase/functions/*&lt;/code&gt;, &lt;code&gt;supabase/config.toml&lt;/code&gt;, SQL migrations, Next.js &lt;code&gt;app/api/*&lt;/code&gt; routes, &lt;code&gt;middleware.ts&lt;/code&gt;, and Express servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't do.&lt;/strong&gt; I didn't contact any deployed app or call any endpoint, and I didn't copy any secret values. Everything here comes from public source code. I'm not naming any repo, because some of these endpoints are probably still live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went right
&lt;/h2&gt;

&lt;p&gt;This part deserves credit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0 of 25&lt;/strong&gt; repos had a hardcoded secret (OpenAI, Anthropic, Stripe secret, AWS, GitHub, Resend, private keys or generic). The same was true across full git history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 of 25&lt;/strong&gt; had the Supabase &lt;code&gt;service_role&lt;/code&gt; key in code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;43 of 43&lt;/strong&gt; Supabase tables defined in migrations (across 4 repos) had Row Level Security enabled.&lt;/li&gt;
&lt;li&gt;4 repos had a committed &lt;code&gt;.env&lt;/code&gt; file, but 3 held only the Supabase URL, project id and publishable key, and the fourth had placeholder or localhost values. &lt;strong&gt;0 live secrets.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also saw a Stripe webhook that properly verified signatures with &lt;code&gt;constructEvent&lt;/code&gt;, and an Express API with auth and rate-limit middleware.&lt;/p&gt;

&lt;p&gt;So "don't put your key in the frontend" has sunk in, both for builders and for the AI tools writing the code. The next lesson hasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patterns
&lt;/h2&gt;

&lt;p&gt;Context first: 11 of the 25 repos had any server or data layer at all. The other 14 were frontend-only. So the numbers below are small, and I'll say exactly how small.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The key is hidden, but the endpoint is open
&lt;/h3&gt;

&lt;p&gt;Two repos called an AI model from the server. Both kept the key in server env vars, which is right. &lt;strong&gt;Both&lt;/strong&gt; exposed the route that uses it with no login check and no per-user rate limit. One was a pair of Supabase edge functions with &lt;code&gt;verify_jwt = false&lt;/code&gt; and CORS &lt;code&gt;*&lt;/code&gt;. The other was three Next.js API routes with no middleware.&lt;/p&gt;

&lt;p&gt;That's n=2, not a statistic. But the result is the same either way: anyone who finds the URL can spend your credits.&lt;/p&gt;

&lt;p&gt;The bad pattern looks something like this (a generic example, not from any repo):&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/generate/route.ts&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;POST&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prompt&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;req&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="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="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&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;The fix is to require a user and limit them:&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="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;POST&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getSessionUser&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="c1"&gt;// your auth&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;success&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;ratelimit&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="nx"&gt;user&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="c1"&gt;// e.g. Upstash, or a DB counter&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Too many requests&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&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;prompt&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;req&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="c1"&gt;// ...same model call&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set a hard monthly spend cap in your AI provider's dashboard. That's your last line of defense if the first two fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Auth switched off right next to the admin key
&lt;/h3&gt;

&lt;p&gt;One push-notification edge function had this in &lt;code&gt;supabase/config.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;functions&lt;/span&gt;&lt;span class="k"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send-notification&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;verify_jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, it built a Supabase client with the &lt;code&gt;service_role&lt;/code&gt; key, which bypasses RLS, and sent notifications whose title, body and recipients came straight from the request body. If no recipients were given, it sent to every subscriber. So anyone could push official-looking alerts to every user.&lt;/p&gt;

&lt;p&gt;Here's a generic version of the shape:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Deno&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userIds&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;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;subscriptions&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...send to targets&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: leave &lt;code&gt;verify_jwt&lt;/code&gt; on (it's the default), check who's calling before you touch the admin client, and never let the request choose "everyone."&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authed&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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;global&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&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;authed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&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;status&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Recipients required&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;h3&gt;
  
  
  3. The policy name lies
&lt;/h3&gt;

&lt;p&gt;One repo had an RLS policy named "Allow insert for authenticated users." It looked something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Allow insert for authenticated users"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;to authenticated&lt;/code&gt;, so it applies to every role, logged in or not. And &lt;code&gt;with check (true)&lt;/code&gt; means any caller can insert a row for any user. RLS was technically on, but this policy let anyone through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users insert their own notifications"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&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;with&lt;/span&gt; &lt;span class="k"&gt;check&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="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or don't allow client inserts at all, and create these rows from a trigger or a trusted server function.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Committed &lt;code&gt;.env&lt;/code&gt; files
&lt;/h3&gt;

&lt;p&gt;4 of 25 repos committed a &lt;code&gt;.env&lt;/code&gt;. None held a live secret this time, but that's luck, not a process. Add &lt;code&gt;.env*&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, commit a &lt;code&gt;.env.example&lt;/code&gt; with placeholder values, and if a real secret ever lands in history, rotate it. Deleting the file doesn't remove it from git history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own app in 10 minutes
&lt;/h2&gt;

&lt;p&gt;You can do all of this by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep your server code for AI calls.&lt;/strong&gt; For each route that calls a model, ask: what stops a logged-out stranger from calling this in a loop?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open &lt;code&gt;supabase/config.toml&lt;/code&gt;.&lt;/strong&gt; Any &lt;code&gt;verify_jwt = false&lt;/code&gt;? Each one needs a written reason (a webhook that verifies its own signature is a good one).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search functions for &lt;code&gt;SERVICE_ROLE&lt;/code&gt;.&lt;/strong&gt; Anywhere it shows up, the caller should be checked first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search migrations for &lt;code&gt;using (true)&lt;/code&gt; and &lt;code&gt;with check (true)&lt;/code&gt;.&lt;/strong&gt; Also check every policy for a &lt;code&gt;to&lt;/code&gt; clause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for request bodies that pick targets.&lt;/strong&gt; User ids, recipient lists, "all."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;git log --all -p | grep -iE "sk-|secret|service_role"&lt;/code&gt;.&lt;/strong&gt; Rotate anything real you find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a spend cap&lt;/strong&gt; with every AI provider you use.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small numbers.&lt;/strong&gt; 2 of 2 is n=2. I'm saying "both," not "100%."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample bias.&lt;/strong&gt; The repos came from GitHub search, filtered by stars and heavy on Lovable. This is a snapshot, not a survey, and a public repo may not match what's deployed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual findings.&lt;/strong&gt; Patterns 1 to 3 came from reading code by hand, not from the automated detectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One false positive excluded.&lt;/strong&gt; A voice-notes app was flagged for an AI key in the client, but it was the user's own bring-your-own key stored locally, so I left it out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public keys aren't leaks.&lt;/strong&gt; 5 repos shipped the Supabase anon key and URL, and 1 shipped a Firebase web config. Those are public by design, so I didn't count them. They do mean your RLS has to be right.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;I turned these checks into &lt;a href="https://hackorbroke.com" rel="noopener noreferrer"&gt;HackOrBroke&lt;/a&gt;. Paste your app's URL, and optionally a public GitHub repo. It looks for leaked keys in your JS bundles and exposed files like &lt;code&gt;/.env&lt;/code&gt; and &lt;code&gt;/.git/config&lt;/code&gt;. It also runs static analysis on the repo for the server patterns above: open AI routes, &lt;code&gt;verify_jwt = false&lt;/code&gt; with the service role key, and loose RLS policies. It never calls your app's APIs.&lt;/p&gt;

&lt;p&gt;The scan is free. The full report is $19 and adds file paths, line numbers and a fix prompt you can paste into your AI tool. Fair warning: auth detection is heuristic, it only reads public repos, and it doesn't support Python yet.&lt;/p&gt;

&lt;p&gt;(Small aside: the business is run day to day by an AI agent I call Frankie, and it was built and launched in a day.)&lt;/p&gt;

&lt;p&gt;If it flags something that isn't a problem, please tell me. False positive reports are the feedback I need most right now.&lt;/p&gt;

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