<?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: SystAgProject</title>
    <description>The latest articles on DEV Community by SystAgProject (@systagproject).</description>
    <link>https://dev.to/systagproject</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3887886%2Feefa192c-4431-46de-9678-d0ea084f2a5e.png</url>
      <title>DEV Community: SystAgProject</title>
      <link>https://dev.to/systagproject</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/systagproject"/>
    <language>en</language>
    <item>
      <title>I Audited 9 Vibe-Coded Apps in 24 Hours. Here Are the 5 Patterns That Show Up Every Single Time.</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Mon, 20 Apr 2026 00:34:55 +0000</pubDate>
      <link>https://dev.to/systagproject/i-audited-9-vibe-coded-apps-in-24-hours-here-are-the-5-patterns-that-show-up-every-single-time-ebk</link>
      <guid>https://dev.to/systagproject/i-audited-9-vibe-coded-apps-in-24-hours-here-are-the-5-patterns-that-show-up-every-single-time-ebk</guid>
      <description>&lt;p&gt;Yesterday I ran &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; — my security-audit tool for AI-generated SaaS — against &lt;strong&gt;9 public apps built on Lovable / Bolt / v0 / Cursor.&lt;/strong&gt; Different verticals: healthcare (patient records), finance (AI script-for-sale platform), productivity (logic/notes tools), crypto (CELO transfers), gym management, blockchain auditing.&lt;/p&gt;

&lt;p&gt;Total: &lt;strong&gt;145 findings, 9 critical, 75 high, 61 medium.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Five patterns showed up in basically every single one. Not "I saw them occasionally" — I saw them in 8-9 out of 9. If you're shipping an AI-coded SaaS right now, these are the ones to fix tonight, before anyone signs up.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. RLS policies with &lt;code&gt;USING (true)&lt;/code&gt; — "looks secure, isn't"
&lt;/h2&gt;

&lt;p&gt;Hit rate: &lt;strong&gt;8 / 9 codebases.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every single app I scanned had Supabase RLS enabled on its core tables. That looks fine — RLS is on. Until you read the policies:&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;"authenticated users can read"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&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="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;If &lt;code&gt;cases&lt;/code&gt; stores per-user data and your app has open signup, this is a fancy way of saying: "anyone who creates a throwaway Gmail account reads every other user's records." I saw this on medical case files, on AI generation history, on gym-member workout logs. On one healthcare app, any signed-in user could read AND modify every patient record + physician note + uploaded file in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For UPDATE policies, add &lt;code&gt;WITH CHECK (user_id = auth.uid())&lt;/code&gt; too, or users can flip the &lt;code&gt;user_id&lt;/code&gt; column during their update and steal other people's rows.&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://dev.to/systagproject/your-first-supabase-rls-policy-without-exposing-your-whole-database-4jam"&gt;a full tutorial on getting RLS right&lt;/a&gt; earlier today — it's the single highest-impact thing you can fix this week.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Unauthenticated edge functions — "verify_jwt = false"
&lt;/h2&gt;

&lt;p&gt;Hit rate: &lt;strong&gt;9 / 9 codebases.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every single app had at least one edge function with &lt;code&gt;verify_jwt = false&lt;/code&gt; in &lt;code&gt;supabase/config.toml&lt;/code&gt;. Sometimes a half-dozen. Most of them were AI endpoints (calls to OpenAI, Gemini, Claude) or payment webhooks.&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="nn"&gt;[functions.payment-webhook]&lt;/span&gt;
&lt;span class="py"&gt;verify_jwt&lt;/span&gt; &lt;span class="p"&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;Payment webhooks &lt;em&gt;need&lt;/em&gt; this — Stripe has to be able to call you without a user JWT. But the fix is then to verify the &lt;strong&gt;signature&lt;/strong&gt; inside the function:&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;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;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&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;STRIPE_WEBHOOK_SECRET&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 7 of the 9 apps, that verification was missing or replaced by a shared secret sent in a custom header (which is worse — the secret travels over the wire on every request and leaks into logs).&lt;/p&gt;

&lt;p&gt;On AI endpoints with &lt;code&gt;verify_jwt = false&lt;/code&gt;, the function is world-callable. Anyone can hit it in a &lt;code&gt;while(true)&lt;/code&gt; loop and drain your OpenAI credits in an hour. On one app the LLM endpoint would happily accept 2MB request bodies. Attacker math: $X credits per request × unlimited requests → bye bye balance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; turn &lt;code&gt;verify_jwt = true&lt;/code&gt; back on for anything that isn't a payment webhook, then verify auth inside the function. For the webhook exceptions, verify the provider signature.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. No rate limit on AI / generation / contact / signup endpoints
&lt;/h2&gt;

&lt;p&gt;Hit rate: &lt;strong&gt;9 / 9 codebases.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;None of the 9 apps had rate limiting in front of their expensive endpoints. Not on AI generation. Not on contact forms. Not on signup. Not on password reset.&lt;/p&gt;

&lt;p&gt;This matters in two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credit drain&lt;/strong&gt;: the AI-generation endpoint on one app would gladly take a 50,000-character prompt and call GPT-4 on it. No cap on requests per user, no cap on input size. A single compromised account runs up a $500 bill overnight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signup / spam flood&lt;/strong&gt;: one app had open signup with no captcha and no rate limit. Combined with the &lt;code&gt;USING (true)&lt;/code&gt; RLS issue, that means an attacker can spin up 10,000 fake accounts and each one gets read access to all the real users' data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt; (Supabase edge functions):&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ratelimit&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;@upstash/ratelimit&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;ratelimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ratelimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt;
  &lt;span class="na"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;:&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;slidingWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 m&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="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;userId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;ip&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;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;rate limited&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For signup, Supabase has a native rate-limit toggle in Auth → Policies. &lt;strong&gt;Turn it on.&lt;/strong&gt; Also enable Turnstile / hCaptcha — &lt;code&gt;supabase-js&lt;/code&gt; supports it natively now on &lt;code&gt;signUp&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. CORS wide open — &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Hit rate: &lt;strong&gt;9 / 9 codebases.&lt;/strong&gt;&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;corsHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&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, x-client-info, apikey, content-type&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every AI coding assistant ships this snippet as the default. On its own, on a public static endpoint, fine. But when you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CORS: *&lt;/code&gt; +&lt;/li&gt;
&lt;li&gt;An authenticated edge function that accepts the &lt;code&gt;Authorization&lt;/code&gt; header +&lt;/li&gt;
&lt;li&gt;A logged-in user visiting a malicious page —&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…then the malicious page can trigger authenticated calls on behalf of the user. They browse to &lt;code&gt;funny-cat-memes.xyz&lt;/code&gt;; in the background their browser fires a DM delete, a subscription upgrade, an AI-credit burn against your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: replace &lt;code&gt;*&lt;/code&gt; with your app's actual origin (or an allow-list):&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;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://myapp.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://myapp.vercel.app&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&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;Origin&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="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;corsHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;null&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Race conditions on balance / credits / usage counters
&lt;/h2&gt;

&lt;p&gt;Hit rate: &lt;strong&gt;6 / 9 codebases&lt;/strong&gt; (but 100% of codebases that had any billing / free-tier concept).&lt;/p&gt;

&lt;p&gt;Classic pattern:&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="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="nx"&gt;usage&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;usage_tracking&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="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;generation_count&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="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;user_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;userId&lt;/span&gt;&lt;span class="p"&gt;)&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;generation_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;quota exceeded&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ... do the expensive AI call ...&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;usage_tracking&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;generation_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;generation_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&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;user_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;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user fires 10 requests simultaneously. All 10 read &lt;code&gt;generation_count = 0&lt;/code&gt;. All 10 pass the &lt;code&gt;&amp;lt; 5&lt;/code&gt; check. All 10 increment. The user got 10 free AI calls on your dime.&lt;/p&gt;

&lt;p&gt;Same pattern shows up on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inventory decrements (oversell — sold 10 units, only had 5)&lt;/li&gt;
&lt;li&gt;Balance transfers (fire 5 parallel withdrawals of $100 from a $100 account)&lt;/li&gt;
&lt;li&gt;Discount code usage (single-use code used 20 times)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: do the check-and-increment &lt;strong&gt;atomically&lt;/strong&gt; in one SQL statement:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;usage_tracking&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;generation_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generation_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;generation_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;generation_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the row isn't updated (no rows returned), the user hit their cap. Either use a Postgres function like this called via &lt;code&gt;rpc()&lt;/code&gt;, or an &lt;code&gt;UPDATE ... WHERE&lt;/code&gt; guard in the edge function. Don't do the check and the update as separate operations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Each of these issues is not a theoretical risk. On &lt;strong&gt;every&lt;/strong&gt; codebase I audited, at least one of these was exploitable by anyone on the internet today — no special tools, no privileged access. A curious user with a browser console can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read all other users' records (pattern 1)&lt;/li&gt;
&lt;li&gt;Upgrade their own account to a paid tier without paying (pattern 2)&lt;/li&gt;
&lt;li&gt;Drain your AI credits in an afternoon (pattern 3)&lt;/li&gt;
&lt;li&gt;Get other users to unknowingly perform actions (pattern 4)&lt;/li&gt;
&lt;li&gt;Bypass every usage limit you've tried to enforce (pattern 5)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're shipping AI-coded apps, these five are the first pass. They take 2-4 hours to fix if you know what you're looking for.&lt;/p&gt;

&lt;p&gt;If you want the list for &lt;em&gt;your&lt;/em&gt; codebase specifically — which findings, which files, which lines, with copy-paste fixes for each — that's &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;what VibeScan does&lt;/a&gt;. $49, runs on a public GitHub repo, PDF report back in the hour. Most first-time scans on a Lovable/Bolt/v0/Cursor app come back with 1 critical + 5-10 high severity findings, roughly half of which are the patterns above.&lt;/p&gt;

&lt;p&gt;Either way: if one of these five rang a bell, go check that code before you close this tab.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>supabase</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your First Supabase RLS Policy, Without Exposing Your Whole Database</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Sun, 19 Apr 2026 23:35:43 +0000</pubDate>
      <link>https://dev.to/systagproject/your-first-supabase-rls-policy-without-exposing-your-whole-database-4jam</link>
      <guid>https://dev.to/systagproject/your-first-supabase-rls-policy-without-exposing-your-whole-database-4jam</guid>
      <description>&lt;p&gt;Every week I audit a handful of AI-generated apps (&lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; is the service behind this). The single most common "how is this in production" finding is a broken Row Level Security policy. Usually it's one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RLS is disabled and the table is just public&lt;/li&gt;
&lt;li&gt;RLS is enabled but every policy is &lt;code&gt;USING (true)&lt;/code&gt; — so it's &lt;em&gt;still&lt;/em&gt; public, it just looks secure&lt;/li&gt;
&lt;li&gt;The policy scopes reads correctly, but the UPDATE policy lets users rewrite their own &lt;code&gt;role = 'admin'&lt;/code&gt; column&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is the RLS primer I wish I could hand to every Lovable / Bolt / v0 user on day one. By the end you'll have a correct policy for a "notes" table where each user sees only their own rows, you'll know how to verify it, and you'll recognize the three patterns that break it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;You have a &lt;code&gt;notes&lt;/code&gt; table. Each row belongs to one user. Your app should let a signed-in user:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read only their own notes&lt;/li&gt;
&lt;li&gt;Create new notes &lt;em&gt;for themselves&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Edit / delete only their own notes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nobody should be able to read, create for, edit, or delete anyone else's notes. Not even anonymous users. Not even signed-in users who know how to open DevTools.&lt;/p&gt;

&lt;p&gt;Here's the schema:&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;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&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;users&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="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;h2&gt;
  
  
  Step 1 — turn RLS on
&lt;/h2&gt;

&lt;p&gt;RLS is off by default. Turn it on explicitly:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment you run this, &lt;em&gt;all&lt;/em&gt; queries against the table return zero rows — for every user, including &lt;code&gt;authenticated&lt;/code&gt;. RLS is deny-by-default; you add policies to carve out what each role can do.&lt;/p&gt;

&lt;p&gt;This is a good thing. If you turn RLS on and your app breaks, you now know exactly which tables need policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — the four policies
&lt;/h2&gt;

&lt;p&gt;One policy per CRUD verb. Each scopes the rows a user can touch.&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="c1"&gt;-- READ: a user sees only their own notes.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes_select_own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;user_id&lt;/span&gt; &lt;span class="o"&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="c1"&gt;-- INSERT: a user can only create rows under their own user_id.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes_insert_own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&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;user_id&lt;/span&gt; &lt;span class="o"&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="c1"&gt;-- UPDATE: a user can edit only their own notes, and can't change the owner.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes_update_own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&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;user_id&lt;/span&gt; &lt;span class="o"&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="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;user_id&lt;/span&gt; &lt;span class="o"&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="c1"&gt;-- DELETE: a user can delete only their own notes.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes_delete_own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;DELETE&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;user_id&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between &lt;code&gt;USING&lt;/code&gt; and &lt;code&gt;WITH CHECK&lt;/code&gt; is the subtle part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt; filters rows you can &lt;em&gt;see&lt;/em&gt; for the operation (the "before" predicate).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt; validates rows you're trying to &lt;em&gt;write&lt;/em&gt; (the "after" predicate).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On UPDATE you need both: &lt;code&gt;USING&lt;/code&gt; to decide which rows you can target, &lt;code&gt;WITH CHECK&lt;/code&gt; to stop you from flipping &lt;code&gt;user_id&lt;/code&gt; to someone else's uid mid-update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — verify the policy actually works
&lt;/h2&gt;

&lt;p&gt;Writing policies is easy. Verifying them is the step most people skip. Supabase ships &lt;code&gt;set role&lt;/code&gt; — use it.&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="c1"&gt;-- Pretend to be user A and insert a row&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="k"&gt;ROLE&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;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="nv"&gt;"request.jwt.claims"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "role": "authenticated"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'hello from A'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ✅ succeeds&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'malicious row for B'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ❌ fails with: "new row violates row-level security policy"&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- returns only A's rows, not B's&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you skip verification, you're trusting your mental model. The mental model is wrong more often than you'd think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three patterns I keep seeing break
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ❌ 1. &lt;code&gt;USING (true)&lt;/code&gt; — "looks like RLS, isn't RLS"
&lt;/h3&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;"authenticated users can read"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&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="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;This is RLS-in-name-only. It lets every signed-in user read every row. If your app has open signup, every visitor can sign up with a throwaway email and read every other user's data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: &lt;code&gt;USING (user_id = auth.uid())&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ❌ 2. UPDATE policy without &lt;code&gt;WITH CHECK&lt;/code&gt;
&lt;/h3&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;"notes_update_own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&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;user_id&lt;/span&gt; &lt;span class="o"&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="c1"&gt;-- no WITH CHECK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user can update their own row (&lt;code&gt;USING&lt;/code&gt; passes), and inside that update flip &lt;code&gt;user_id&lt;/code&gt; to another user's id. Now that note belongs to someone else.&lt;/p&gt;

&lt;p&gt;Same pattern bites harder on a &lt;code&gt;profiles&lt;/code&gt; table that has a &lt;code&gt;role&lt;/code&gt; or &lt;code&gt;subscription_tier&lt;/code&gt; column. The user can UPDATE their own profile and set &lt;code&gt;role = 'admin'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: add &lt;code&gt;WITH CHECK (user_id = auth.uid())&lt;/code&gt;. On sensitive columns like &lt;code&gt;role&lt;/code&gt; or &lt;code&gt;subscription_tier&lt;/code&gt;, go further — split them into a separate table that only &lt;code&gt;service_role&lt;/code&gt; can write.&lt;/p&gt;

&lt;h3&gt;
  
  
  ❌ 3. Admin actions done from the client
&lt;/h3&gt;

&lt;p&gt;The third pattern isn't an RLS mistake directly — it's a consequence of trying to get around RLS. Someone needs to be able to do something "admin-ish" (mark a payment as completed, promote a user), so they write a policy that lets any authenticated user do the write. Then everyone can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: keep the RLS policy strict, and move admin actions into a Supabase Edge Function that uses the &lt;code&gt;service_role&lt;/code&gt; key. The &lt;code&gt;service_role&lt;/code&gt; bypasses RLS by design — that's its job. Just make sure the function itself verifies the caller has the right permission before doing the write.&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;// edge function&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;npm:@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;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_URL&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="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="c1"&gt;// bypasses RLS&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// verify the caller is authenticated *and* has admin role&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="o"&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="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;supabase&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="nx"&gt;jwt&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="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="c1"&gt;// check user.app_metadata.role === "admin" or a user_roles table lookup&lt;/span&gt;

&lt;span class="c1"&gt;// now you can do the write that regular authenticated users can't&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;payments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&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;paymentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The 10-minute self-audit
&lt;/h2&gt;

&lt;p&gt;Run this against your own Supabase project before you ship:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For every public table, is RLS enabled? (&lt;code&gt;SELECT tablename FROM pg_tables WHERE schemaname = 'public'&lt;/code&gt; and check each.)&lt;/li&gt;
&lt;li&gt;For every enabled table, are there policies for &lt;code&gt;SELECT / INSERT / UPDATE / DELETE&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Do any policies use &lt;code&gt;USING (true)&lt;/code&gt; or &lt;code&gt;WITH CHECK (true)&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;On every UPDATE policy, is there a &lt;code&gt;WITH CHECK&lt;/code&gt; that prevents ownership / role flipping?&lt;/li&gt;
&lt;li&gt;Are there any columns on public-readable tables that shouldn't be readable (Stripe customer IDs, internal notes, moderator flags)? Columns with &lt;code&gt;SELECT&lt;/code&gt; granted to &lt;code&gt;authenticated&lt;/code&gt; are readable by &lt;em&gt;every&lt;/em&gt; signed-in user whose policy match returns a row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want this done automatically on your full codebase — including the 40 other security patterns that show up in AI-generated apps — that's what I built &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; for. $49, runs on your public GitHub repo, PDF report with severity-graded findings and copy-paste fixes. Most Lovable / Bolt / v0 / Cursor-built apps come back with 1 critical + 5-10 high severity findings on first scan, and roughly half of them are RLS patterns like the ones above.&lt;/p&gt;

&lt;p&gt;Either way — if you've read this far and your app has a &lt;code&gt;USING (true)&lt;/code&gt; policy somewhere, go fix it before you close this tab.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 12 Security Issues I Keep Finding in Vibe-Coded Apps (Lovable, Bolt, v0)</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Sun, 19 Apr 2026 22:12:29 +0000</pubDate>
      <link>https://dev.to/systagproject/the-12-security-issues-i-keep-finding-in-vibe-coded-apps-lovable-bolt-v0-786</link>
      <guid>https://dev.to/systagproject/the-12-security-issues-i-keep-finding-in-vibe-coded-apps-lovable-bolt-v0-786</guid>
      <description>&lt;p&gt;Over the last few weeks I've been running &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;&lt;strong&gt;VibeScan&lt;/strong&gt;&lt;/a&gt; — a security audit tool for AI-generated codebases — against a small set of public Lovable / Bolt / v0 / Cursor apps. Same dozen issues keep surfacing.&lt;/p&gt;

&lt;p&gt;If you're shipping a vibe-coded SaaS, run through this list before launch. It'll take you 30 minutes and save you from the most common self-own patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Payment webhook has &lt;code&gt;verify_jwt = false&lt;/code&gt; and no signature check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What you'll find in your repo&lt;/strong&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="c"&gt;# supabase/config.toml&lt;/span&gt;
&lt;span class="nn"&gt;[functions.payment-webhook]&lt;/span&gt;
&lt;span class="py"&gt;verify_jwt&lt;/span&gt; &lt;span class="p"&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;And inside the function, no &lt;code&gt;stripe.webhooks.constructEvent(...)&lt;/code&gt; before trusting the event body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; The endpoint is world-reachable. Anyone can &lt;code&gt;curl&lt;/code&gt; it with a fake &lt;code&gt;"type": "checkout.session.completed"&lt;/code&gt; body and flip a row in your &lt;code&gt;profiles&lt;/code&gt; table. Free Pro tier for everyone on the internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (one line-change + one env var)&lt;/strong&gt;&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;signatureHeader&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. RLS policies using &lt;code&gt;USING (true)&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What you'll find&lt;/strong&gt;&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;"authenticated users can read"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&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="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;If &lt;code&gt;cases&lt;/code&gt; is "any record" — not "my records" — then any signed-in user reads all the data. Open signup + &lt;code&gt;USING (true)&lt;/code&gt; + RLS enabled = a fancy way to display your entire database to any visitor who clicks "Sign up".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: scope by ownership.&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;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make sure you actually set &lt;code&gt;user_id = auth.uid()&lt;/code&gt; on INSERT with a &lt;code&gt;WITH CHECK&lt;/code&gt; clause.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. API keys prefixed with &lt;code&gt;VITE_&lt;/code&gt; — shipped to every browser
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/ResumeUpload.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&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;meta&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;VITE_GEMINI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything with &lt;code&gt;VITE_&lt;/code&gt; / &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; / &lt;code&gt;REACT_APP_&lt;/code&gt; is in the client bundle. Open DevTools → Network tab → find any request with the key in Authorization → paste it into Postman.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: move the API call to a Supabase Edge Function (or Next.js server route) that holds the key server-side. The browser calls &lt;em&gt;your&lt;/em&gt; endpoint; your endpoint calls the vendor.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. No rate limit on the expensive LLM endpoint
&lt;/h2&gt;

&lt;p&gt;Your &lt;code&gt;generate-something&lt;/code&gt; endpoint runs an Opus / GPT-4 call. It accepts an arbitrary-length prompt. There's no cap on requests per user.&lt;/p&gt;

&lt;p&gt;Someone writes a &lt;code&gt;while(true)&lt;/code&gt; loop in the console. Your monthly AI bill is now $4k.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: two lines with Upstash.&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="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;userId&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;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;rate limited&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Profile row created from the client
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After signUp({ email, password })&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;profiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&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="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="nx"&gt;name&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem isn't the insert. It's that any signed-in user can do an UPDATE with &lt;code&gt;role = "admin"&lt;/code&gt; if your RLS policy lets the user write to their own row and the &lt;code&gt;role&lt;/code&gt; column isn't excluded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: move profile creation to a Postgres trigger on &lt;code&gt;auth.users&lt;/code&gt;:&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;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;handle_new_user&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&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;users&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And restrict the &lt;code&gt;profiles.role&lt;/code&gt; column from client UPDATEs.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Subscription tier writable from the client
&lt;/h2&gt;

&lt;p&gt;This is the evil cousin of #5. You have a &lt;code&gt;profiles.subscription_tier&lt;/code&gt; column. Your RLS allows &lt;code&gt;UPDATE FOR authenticated USING (user_id = auth.uid())&lt;/code&gt;. Any user opens console, runs:&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;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;profiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subscription_tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&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;myId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. Lifetime Pro access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: &lt;code&gt;subscription_tier&lt;/code&gt; is a server-only column. Update it in a trigger that fires from your payment webhook, and revoke &lt;code&gt;UPDATE&lt;/code&gt; on that column from the &lt;code&gt;authenticated&lt;/code&gt; role:&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;UPDATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription_tier&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;profiles&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Uploaded files readable by any logged-in user
&lt;/h2&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;"read uploads"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'case-files'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who signs up can download every file in the bucket. Particularly painful when the bucket has resumes, medical records, or passport scans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: encode the user ID in the path and check it in the policy.&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;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'case-files'&lt;/span&gt; &lt;span class="k"&gt;AND&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="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  8. Hardcoded &lt;code&gt;SUPABASE_URL&lt;/code&gt; + anon key as fallbacks
&lt;/h2&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;SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&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;meta&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;VITE_SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://abc123.supabase.co&lt;/span&gt;&lt;span class="dl"&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 anon key is &lt;em&gt;technically&lt;/em&gt; public (it's designed to be shipped to browsers). But hardcoding it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can't rotate without shipping a new build.&lt;/li&gt;
&lt;li&gt;You can't use the same codebase for staging / prod.&lt;/li&gt;
&lt;li&gt;If someone ever adds the &lt;strong&gt;service_role&lt;/strong&gt; key by mistake under the same pattern, it's game over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: &lt;code&gt;throw new Error("missing env var")&lt;/code&gt; at build time if the var is missing. No fallback.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Weak password policy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt; &lt;span class="na"&gt;minLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six characters is brute-forceable in under a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: &lt;code&gt;minLength={10}&lt;/code&gt; on the input, &lt;em&gt;and&lt;/em&gt; enforce a floor in Supabase Auth settings → Policies → Password requirements. Also turn on the "leaked password check".&lt;/p&gt;




&lt;h2&gt;
  
  
  10. CORS wide open on server actions
&lt;/h2&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;corsHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST, OPTIONS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;*&lt;/code&gt; is correct for static public endpoints. It's not correct for an endpoint that returns user-specific data or does a sensitive action on a cookie-authenticated session. Any site the victim visits can &lt;code&gt;fetch()&lt;/code&gt; your API with their creds attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: echo the &lt;code&gt;Origin&lt;/code&gt; header back only if it matches an allowlist. Or just hardcode your app's domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. No input validation on write endpoints
&lt;/h2&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audience&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;// straight into the LLM prompt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;zod.parse(...)&lt;/code&gt;. No length cap. Someone sends a 500 KB prompt. Your model call burns $3 and times out. Multiply by 10k loops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;:&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;keyMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  12. Credits / balance updates that aren't atomic
&lt;/h2&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;credits&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;users&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;credits&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;userId&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;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;await&lt;/span&gt; &lt;span class="nf"&gt;doTheExpensiveThing&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;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;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;userId&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;Classic race. User fires two parallel requests, both read &lt;code&gt;credits = 1&lt;/code&gt;, both proceed, both decrement. One free call. In the worst case, it's 50 parallel calls for one credit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: atomic decrement in a single statement (or a Postgres function):&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the returned row is empty, reject the request.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to check your own repo in 5 minutes
&lt;/h2&gt;

&lt;p&gt;Manual approach: grep the repo for these patterns.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;verify_jwt = false&lt;/code&gt; in &lt;code&gt;supabase/config.toml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USING (true)&lt;/code&gt; in &lt;code&gt;*.sql&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VITE_.*_KEY&lt;/code&gt; / &lt;code&gt;NEXT_PUBLIC_.*_KEY&lt;/code&gt; / &lt;code&gt;REACT_APP_.*_KEY&lt;/code&gt; in source&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minLength={6}&lt;/code&gt; in auth forms&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; in server functions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;corsHeaders&lt;/code&gt; without an allowlist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a cleaner version of the above as a per-repo PDF with every finding graded and a copy-paste fix for each, that's what &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; is. It clones your repo, runs a multi-batch audit with Claude Opus 4.7, and spits out a severity-graded report. $49 one-time. Typical finding count for a 3-month-old vibe-coded app is 6-15 issues, 1-2 of them critical.&lt;/p&gt;

&lt;p&gt;If you want me to run it on your repo for free in exchange for feedback, reply to me on &lt;a href="https://x.com" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt; or send me the repo URL.&lt;/p&gt;

&lt;p&gt;Stay safe out there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>supabase</category>
    </item>
    <item>
      <title>I ran a security audit on my own Python codebase with an LLM for $0.90. Here is what it found.</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Sun, 19 Apr 2026 21:35:06 +0000</pubDate>
      <link>https://dev.to/systagproject/i-ran-a-security-audit-on-my-own-python-codebase-with-an-llm-for-090-here-is-what-it-found-151c</link>
      <guid>https://dev.to/systagproject/i-ran-a-security-audit-on-my-own-python-codebase-with-an-llm-for-090-here-is-what-it-found-151c</guid>
      <description>&lt;p&gt;Last week I shipped a small product called VibeScan — a 49-dollar PDF security audit for apps built with Lovable / Bolt / Cursor / Replit / v0. Before I asked anyone to pay for it, I ran it on my own codebase as a smoke test.&lt;/p&gt;

&lt;p&gt;124 scannable Python files, 4 LLM batches, 22 seconds total wall time. Audit cost: $0.90 of Opus 4.7 with prompt caching. Output: 0 critical findings, 1 high, 2 medium. One of the findings was a real bug I fixed the same hour. The other two were legitimate risk flags I had not thought about.&lt;/p&gt;

&lt;p&gt;Here is the full report, with context on each finding.&lt;/p&gt;




&lt;h2&gt;
  
  
  [HIGH] Subprocess stdout/stderr written to the ledger without size cap
&lt;/h2&gt;

&lt;p&gt;Location:  — the function  that spawns every scheduled job.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A runaway script that prints megabytes of logs (for example a scraper dumping HTML) will push all of that into your SQLite ledger, potentially bloating the database and causing memory issues during capture. A single bad run could write hundreds of MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: In , truncate stdout/stderr to the last ~10KB before returning (e.g., ) so oversized output cannot blow up the ledger or memory.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why this matters
&lt;/h3&gt;

&lt;p&gt;I was using Python subprocess.run with . That flag tells Python to hold the subprocess full stdout and stderr in memory until the child exits. Which is fine when a cron job prints 50 lines and exits. But if one of those jobs is a web scraper that dumps the HTML of every page it visits, or an ETL that prints a row per record processed on a million-row table, every byte of that output sits in the scheduler process RAM before being written to the SQLite ledger.&lt;/p&gt;

&lt;p&gt;I had never thought about it. The scheduler had run for weeks without hitting this because all the current jobs are well-behaved. But the next job anyone adds could be the one that dumps 500 MB on a bad day.&lt;/p&gt;

&lt;p&gt;The fix took four minutes: cap each stream at 50 KB before returning. If a security auditor had flagged this I would have paid $200. VibeScan cost $0.90 for the whole repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  [MEDIUM] Gmail OAuth refresh token stored in plaintext
&lt;/h2&gt;

&lt;p&gt;Location:  line 30 — the function that loads Google OAuth credentials.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Gmail refresh token is saved as a plain JSON file on disk. Anyone who can read that file (backup, stolen laptop, server compromise) gains indefinite access to send and read email as the account owner — refresh tokens do not expire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: At minimum, ensure credentials/ is in .gitignore and file permissions are 0600; for stronger protection, encrypt the token at rest (for example via OS keyring or an encrypted env var) and document revocation via Google Account -&amp;gt; Security -&amp;gt; Third-party apps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Classic defense-in-depth issue. No immediate exploitation, but the kind of thing you kick yourself over if it ever leaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  [MEDIUM] HTML email bodies stripped with naive regex
&lt;/h2&gt;

&lt;p&gt;Location:  line 215 — the function  that normalizes inbound email.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;extract_plain_body uses a regex to strip HTML tags from inbound mail, which can leave script/style contents, encoded entities, or malformed markup in the plain text the classifier sees. If that text is later fed into an LLM prompt or surfaced to a user, attacker-crafted emails can smuggle content that was not visible as HTML.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The inbound email pipeline feeds the plain text version into an LLM classifier (to route support emails). Because downstream is an LLM, this is a &lt;strong&gt;prompt injection surface&lt;/strong&gt;. A sophisticated spammer who knows we route email via LLM can craft HTML with hidden content in style tags or HTML comments that appears empty to a human recipient but becomes visible instructions in the LLM input.&lt;/p&gt;

&lt;p&gt;Not exploited today. But it is the exact class of bug that becomes headline news in 18 months, and the fix is a 20-line swap to BeautifulSoup.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this scan cost and what it missed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input tokens&lt;/strong&gt;: 176,364 with prompt caching across 4 batches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output tokens&lt;/strong&gt;: 779&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wall time&lt;/strong&gt;: 22 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct infrastructure cost&lt;/strong&gt;: $0.90&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consultant equivalent would be 3-5 hours on a 124-file repo, billed $600-1500, producing a report that needs translation by an engineer to be actionable. The VibeScan report is in the language the buyer speaks and includes the exact line to change.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the scan missed (honest limitations)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Business logic flaws&lt;/strong&gt; like a checkout that trusts client-side prices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency issues&lt;/strong&gt; in state updates (requires runtime tracing, not static read).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency vulnerabilities&lt;/strong&gt; — we do not cross-reference package.json against CVE databases. Snyk does that better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production infra&lt;/strong&gt; — we scan the code, not deployed infrastructure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a solo founder running an AI-coded app, the findings VibeScan catches are where the actual failures come from. For enterprise eng teams with dedicated security engineers, Snyk plus manual review plus threat modeling is the better playbook.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it on your own repo
&lt;/h2&gt;

&lt;p&gt;If you shipped something with Lovable / Bolt / Cursor / Replit / v0 and you are about to take real money from real users — get a second set of eyes on the code first.&lt;/p&gt;

&lt;p&gt;$49, one-time, PDF in ~10 minutes: &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;systag.gumroad.com/l/vibescan&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First 10 readers of this post get it for free — DM me the repo URL and I will send the PDF back within the day.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>python</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
