<?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>Forem: SystAgProject</title>
    <description>The latest articles on Forem by SystAgProject (@systagproject).</description>
    <link>https://forem.com/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>Forem: SystAgProject</title>
      <link>https://forem.com/systagproject</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/systagproject"/>
    <language>en</language>
    <item>
      <title>I Audited 21 Public Vibe-Coded Apps in 48 Hours. Here Are the 5 Patterns That Keep Showing Up.</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Mon, 20 Apr 2026 09:56:50 +0000</pubDate>
      <link>https://forem.com/systagproject/i-audited-21-public-vibe-coded-apps-in-48-hours-here-are-the-5-patterns-that-keep-showing-up-1chk</link>
      <guid>https://forem.com/systagproject/i-audited-21-public-vibe-coded-apps-in-48-hours-here-are-the-5-patterns-that-keep-showing-up-1chk</guid>
      <description>&lt;p&gt;Over the last 48 hours I've run &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; — my LLM-powered security audit for AI-generated SaaS — against &lt;strong&gt;21 public apps&lt;/strong&gt; built on Lovable, Bolt, v0, Cursor, Replit, and Windsurf. I wanted to check whether &lt;a href="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"&gt;the 5 patterns I found in 9 apps earlier this week&lt;/a&gt; were a small-sample fluke or a real signal.&lt;/p&gt;

&lt;p&gt;At 21 apps the signal is unmistakable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total findings across the corpus: 20 critical + 84 high + 58 medium = 162 real issues.&lt;/strong&gt; Every single app had at least one. The most egregious had 13 (1 critical / 8 high / 4 medium). The "cleanest" still had 3 mediums.&lt;/p&gt;

&lt;p&gt;Here's the ranked list of what keeps showing up — hit rate as &lt;strong&gt;unique codebases affected out of 21&lt;/strong&gt;, not total finding count. So "9/21" means that pattern appeared in 9 distinct apps, which is basically half.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Missing authentication / auth bypass on serverless endpoints
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit rate: 9/21 codebases (43%).&lt;/strong&gt; Most common severity: critical / high.&lt;/p&gt;

&lt;p&gt;The defining pattern: the app's server-side code (Supabase Edge Function, Next.js API route, Express route, Cloudflare Worker) trusts whatever the client says without verifying a JWT. Real finding titles from the corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Every API and WebSocket endpoint is completely unauthenticated"&lt;/em&gt; (&lt;code&gt;backend-mock/server.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Phone OTP verify accepts any Firebase UID without verification"&lt;/em&gt; (&lt;code&gt;supabase/functions/auth-phone-otp/index.ts&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Sensitive Supabase Edge Functions have JWT verification disabled"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Calling, contacts, and notification routes accept any user ID with no auth"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"send-daily-email edge function has JWT verification disabled"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scariest sub-class I saw: a function that accepts &lt;code&gt;user_id&lt;/code&gt; from the request body and uses it with a service-role DB client (which bypasses Row-Level Security). Net effect: one &lt;code&gt;curl&lt;/code&gt; command lets anyone impersonate any user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: every sensitive function needs ~20 lines of &lt;code&gt;supabase.auth.getUser()&lt;/code&gt; at the top — verify the &lt;code&gt;Authorization&lt;/code&gt; header, reject if invalid, use the verified &lt;code&gt;user.id&lt;/code&gt; (not the request body) as identity. I wrote &lt;a href="https://dev.to/systagproject/your-supabase-edge-function-probably-has-no-auth-8-out-of-9-vibe-coded-apps-i-scanned-this-week-4lb1"&gt;the full pattern + curl test vectors here&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. No rate limiting on sensitive endpoints
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit rate: 9/21 codebases (43%).&lt;/strong&gt; Most common severity: high.&lt;/p&gt;

&lt;p&gt;Every endpoint that touches email (&lt;code&gt;/api/contact&lt;/code&gt;), authentication (&lt;code&gt;/api/signup&lt;/code&gt;, &lt;code&gt;/api/forgot-password&lt;/code&gt;), LLM calls (&lt;code&gt;/api/scan&lt;/code&gt;, &lt;code&gt;/api/chat&lt;/code&gt;), or any mutable action takes unlimited requests. Real finding titles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"No rate limiting on message send or any endpoint"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"No input validation or rate limiting on QR login endpoint"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"No rate limiting on /api/scan endpoint"&lt;/em&gt; (LLM call — direct cost exposure)&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Public stats endpoint has no rate limit"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"No rate limiting on signup endpoint"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single script with 10 parallel connections can exhaust your Supabase egress, SendGrid credits, OpenAI quota, or Stripe webhook window in under 60 seconds. The attacker doesn't even need a bug — just availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: &lt;a href="https://upstash.com/docs/redis/sdks/ratelimit-ts/overview" rel="noopener noreferrer"&gt;Upstash Ratelimit&lt;/a&gt; is 5 lines per endpoint. &lt;code&gt;Ratelimit.slidingWindow(20, "60 s")&lt;/code&gt; keyed on IP (or &lt;code&gt;user.id&lt;/code&gt; after auth is added). Their free tier covers most side projects.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Dangerous CORS / wildcard origin
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit rate: 7/21 codebases (33%).&lt;/strong&gt; Most common severity: high.&lt;/p&gt;

&lt;p&gt;Either &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; with credentials (browsers will silently ignore the wildcard but the intent is to allow everything), OR a localhost+production origin list that includes dev-server origins in prod. Real findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"CORS wide-open on all API and WebSocket endpoints"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Wide-open CORS on all API endpoints"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Local API server accepts requests from any website (wildcard CORS)"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common scaffolding pattern: CORS gets turned on loose during dev (because it's annoying), then never tightened before deploy. The &lt;code&gt;cors({origin: '*'})&lt;/code&gt; line survives into production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: replace the wildcard with an allowlist of your actual production domain(s) + &lt;code&gt;localhost&lt;/code&gt; for dev. If you genuinely need public API access, don't send credentials with the requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Client-side trust / admin-by-flag
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit rate: 6/21 codebases (32%).&lt;/strong&gt; Most common severity: high / medium.&lt;/p&gt;

&lt;p&gt;The pattern: the client reads a &lt;code&gt;profiles.is_admin&lt;/code&gt; column (or equivalent) and toggles admin UI based on it. The backend then trusts that same flag for gatekeeping admin actions — but because the profile row is user-writable (via an insufficiently restrictive RLS policy), the user can flip their own flag and become admin.&lt;/p&gt;

&lt;p&gt;Real finding titles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Admin status determined by client-side profile read"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Client trusts is_admin flag from profiles table"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Users can set their own order total and status"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Admin 'list all sessions' endpoint called from browser with no auth check"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: admin state lives in a separate table users can't write to, OR in a Supabase JWT custom claim set by a trusted admin-grant function. Never read admin status from a column the user's session can update.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Unsafe file upload (missing MIME check / size cap)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit rate: 3/21 codebases (14%).&lt;/strong&gt; Most common severity: medium / high.&lt;/p&gt;

&lt;p&gt;User-uploaded files (profile photos, document attachments, product images) land in a public storage bucket with no MIME-type whitelist, no size cap beyond the default 50 MB, and often no path-sanitization. Real findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"plant-images storage bucket is public with no file-type or size restrictions"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Product image uploads have no MIME type or size restrictions"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Request body parsed as JSON with no size limit or schema validation"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The attack: upload a 4 GB file disguised as a PNG. Exhaust storage quota, inflate your bill, maybe get the file served with the wrong MIME type (if Supabase Storage trusts the upload extension) and use it as a vector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: whitelist MIME types explicitly in the Supabase storage policy. Cap file size at the use-case-realistic upper bound (500 KB for profile photos, 5 MB for attachments). Validate the magic bytes server-side, don't trust the extension.&lt;/p&gt;




&lt;h2&gt;
  
  
  The remainder
&lt;/h2&gt;

&lt;p&gt;Lower-hit-rate patterns that still showed up in the corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weak password / credential handling&lt;/strong&gt; — 3/21 (14%) — plain SHA-256, minimum length 6 chars, password derived from phone number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissive or missing RLS&lt;/strong&gt; — 2/21 (10%) — &lt;code&gt;world-readable&lt;/code&gt; tables, rate-limit tables with no RLS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing input validation / no schema&lt;/strong&gt; — 2/21 (10%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment / debug / error leak&lt;/strong&gt; — 2/21 (10%) — stack traces in prod, &lt;code&gt;console.log&lt;/code&gt; with secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committed secrets&lt;/strong&gt; — 1/21 (5%) — service-role key in the client bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL injection / query construction risk&lt;/strong&gt; — 1/21 (5%) — ILIKE wildcard injection allowing data exfil.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret in URL query-param&lt;/strong&gt; — 1/21 (5%) — yes, caught this in my own tool before I shipped it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single app had at least three patterns from the above list. The average was &lt;strong&gt;7.7 findings per app&lt;/strong&gt;, the worst was &lt;strong&gt;21 findings in a single codebase&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;If your app was scaffolded by Lovable, Bolt, v0, Cursor, Replit, or Windsurf in the last 6 months, the odds that at least one of the top-5 patterns above applies to you are high — the #1 and #2 patterns alone cover 43% of codebases each, and the top 4 collectively hit 85%+ of the corpus at least once.&lt;/p&gt;

&lt;p&gt;Three ways to check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Free preview scan.&lt;/strong&gt; Email &lt;code&gt;mike.j.kaplan+scan@gmail.com&lt;/code&gt; with your GitHub repo URL. Subject line &lt;code&gt;VibeScan preview&lt;/code&gt;, body the URL. I'll run a preview scan (~25% of your code, highest-risk files) and reply with the top finding within ~5 minutes. No signup, no catch, no upsell unless the scan finds something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full audit.&lt;/strong&gt; $49 one-time at &lt;a href="https://systag.gumroad.com/l/vibescan?utm_source=devto_post_7&amp;amp;utm_medium=web&amp;amp;utm_campaign=research_21_apps" rel="noopener noreferrer"&gt;systag.gumroad.com/l/vibescan&lt;/a&gt;. Every finding severity-graded, every finding with the exact file + line + copy-paste fix. PDF delivered in ~10 minutes. 7-day refund, no questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do the top-5 yourself.&lt;/strong&gt; Links above to detailed writeups on patterns #1 (edge function auth) and the broader &lt;a href="https://dev.to/systagproject/the-12-security-issues-i-keep-finding-in-vibe-coded-apps-lovable-bolt-v0-786"&gt;12-issue checklist&lt;/a&gt;. Budget 30-60 minutes and you're in the top decile of vibe-coded apps.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Methodology (for the skeptics)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;21 public apps from GitHub search for &lt;code&gt;"Built with Lovable"&lt;/code&gt;, &lt;code&gt;"Made with bolt.new"&lt;/code&gt;, &lt;code&gt;"v0.dev"&lt;/code&gt;, &lt;code&gt;"Built with Cursor"&lt;/code&gt;, &lt;code&gt;"Built with Replit Agent"&lt;/code&gt;, &lt;code&gt;"built with Windsurf"&lt;/code&gt; in README.&lt;/li&gt;
&lt;li&gt;Cloned each to a disposable sandbox. Audited via Claude Opus 4.7 against a tuned system prompt (12 failure categories: leaked secrets / missing auth / SQL injection / unvalidated input / unsafe file handling / CORS gaps / rate limits / race conditions / weak hashing / env leaks / logging gaps / client-trust bugs).&lt;/li&gt;
&lt;li&gt;8 preview-tier audits (1 batch, ~12 files each, ~$0.15/run). 13 full-tier audits (up to 4 batches, ~50 files each, ~$1/run). Total corpus cost: $12.&lt;/li&gt;
&lt;li&gt;Severity bar: &lt;code&gt;critical&lt;/code&gt; = auth bypass / secrets exposure / SQL injection with user-controlled input / arbitrary file read-write. &lt;code&gt;high&lt;/code&gt; = missing rate limit on sensitive endpoint / unvalidated persisted input / dangerous CORS / admin-by-client-flag. &lt;code&gt;medium&lt;/code&gt; = weak error handling / theoretical but plausible attack paths / cryptographic oddities.&lt;/li&gt;
&lt;li&gt;Findings clustered into pattern buckets via substring matching on titles. Some findings didn't match any bucket ("Other" category, excluded from the top-5 table).&lt;/li&gt;
&lt;li&gt;No repo was cherry-picked. First 21 results from the queries that passed the size-and-star filters (size 100 KB - 30 MB, stars &amp;lt; 800) were audited in order. One clone failed with no scannable files (excluded).&lt;/li&gt;
&lt;li&gt;Pattern bucket definitions + the full pipeline code are public in the VibeScan repo under &lt;code&gt;scripts/bulk_research_audit.py&lt;/code&gt; and &lt;code&gt;scripts/research_aggregate.py&lt;/code&gt; — run them yourself if you want to verify or expand the corpus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— Michael&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>supabase</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Ran My Own Security Audit Tool Against My Own Codebase. It Caught a Bug I'd Shipped to Main.</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Mon, 20 Apr 2026 09:48:27 +0000</pubDate>
      <link>https://forem.com/systagproject/i-ran-my-own-security-audit-tool-against-my-own-codebase-it-caught-a-bug-id-shipped-to-main-5ah9</link>
      <guid>https://forem.com/systagproject/i-ran-my-own-security-audit-tool-against-my-own-codebase-it-caught-a-bug-id-shipped-to-main-5ah9</guid>
      <description>&lt;p&gt;I built &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; — an LLM-powered security audit for AI-generated SaaS apps. It's $49, it produces a PDF, it's for founders shipping on Lovable / Bolt / v0 / Cursor.&lt;/p&gt;

&lt;p&gt;Today I pointed it at its own codebase. The tool's author is me. The repo is the same Python + Claude Agent orchestration layer I use to run the audits. If the tool works, it should find real bugs in any codebase — including mine. If it doesn't find anything in mine, either my code is unusually clean (it isn't) or the tool is a placebo.&lt;/p&gt;

&lt;p&gt;It found &lt;strong&gt;2 HIGH findings.&lt;/strong&gt; 0 critical, 0 medium. One was mitigated. One was a real leak I'd shipped to &lt;code&gt;main&lt;/code&gt;. Here's the receipt.&lt;/p&gt;




&lt;h2&gt;
  
  
  The finding
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Apify&lt;/span&gt; &lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="nf"&gt;parameter &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leaks&lt;/span&gt; &lt;span class="n"&gt;into&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;crawl_apify_reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;165&lt;/span&gt;

&lt;span class="n"&gt;The&lt;/span&gt; &lt;span class="n"&gt;Apify&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;appended&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;URLs&lt;/span&gt; &lt;span class="n"&gt;routinely&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;captured&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HTTP&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
&lt;span class="n"&gt;stack&lt;/span&gt; &lt;span class="n"&gt;traces&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;anyone&lt;/span&gt; &lt;span class="n"&gt;who&lt;/span&gt; &lt;span class="n"&gt;sees&lt;/span&gt; &lt;span class="n"&gt;those&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="n"&gt;paid&lt;/span&gt;
&lt;span class="n"&gt;Apify&lt;/span&gt; &lt;span class="n"&gt;actors&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rack&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="n"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="n"&gt;Fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;In&lt;/span&gt; &lt;span class="nf"&gt;_api_call&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;pass&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;via&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;Authorization&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;
&lt;span class="n"&gt;instead&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;appending&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="n"&gt;Apify&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s documented auth
options.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened the file. The function looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_api_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;APIFY_API_BASE&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# ... send the request ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL ends up looking like &lt;code&gt;https://api.apify.com/v2/acts/&amp;lt;actor-id&amp;gt;/runs?token=apify_api_&amp;lt;60char_secret&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That URL hits the wire. My HTTP client doesn't log it, but any corporate proxy, any reverse proxy, any Python stack trace that includes the request URL — they all capture the token. If any of those logs leak (or if I share a stack trace in a bug report), the token leaks. The token in question runs paid Apify actors on my account. Somebody with it can burn my budget faster than I can rotate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How did I ship this?&lt;/strong&gt; Probably the same way anyone ships it: the Apify quickstart docs show &lt;code&gt;?token=...&lt;/code&gt; as the first example. I copied the pattern during a session where the goal was "get the API working", not "secure the token flow." The code worked, the tests passed, I moved on. Classic velocity-over-security.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Apify documents both &lt;code&gt;?token=...&lt;/code&gt; and &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; as supported auth forms. The header is strictly safer — no URL logging, no referrer leak, no stack-trace capture.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;APIFY_API_BASE&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;APIFY_API_BASE&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fewer lines. One fewer import (&lt;code&gt;urllib.parse.quote&lt;/code&gt; no longer needed). One fewer leak surface.&lt;/p&gt;

&lt;p&gt;I committed the fix, re-ran the smoke tests, and pushed to &lt;code&gt;main&lt;/code&gt; — 14 minutes from finding to fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The other finding (already mitigated)
&lt;/h2&gt;

&lt;p&gt;VibeScan also flagged this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;Gmail&lt;/span&gt; &lt;span class="n"&gt;OAuth&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;directory&lt;/span&gt;
&lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;gmail_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;37&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Gmail refresh token for my automation lives in &lt;code&gt;credentials/gmail_oauth_token.json&lt;/code&gt;. If someone got that file, they could read and send email from my account until I revoke the token manually — refresh tokens don't expire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This one I'd already mitigated&lt;/strong&gt;, but only by convention:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;credentials/&lt;/code&gt; is in &lt;code&gt;.gitignore&lt;/code&gt;, so the token has never been committed.&lt;/li&gt;
&lt;li&gt;The hub repo is private.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two together mean an attacker would need to compromise my Windows box to get the token. That's a real threat model (see: every malware-infected dev box in existence), but it's not the same class as "anyone with proxy logs can read the token." VibeScan was right to flag it; the severity in my specific setup is lower than in the default threat model the finding assumes.&lt;/p&gt;

&lt;p&gt;The cleaner long-term fix is to move the token to the OS keychain (Windows Credential Manager) and load it from there. I've filed that as a backlog item rather than chasing it this week — the mitigation bar is already higher than most security findings I've seen in customers' codebases.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters more than a clean audit
&lt;/h2&gt;

&lt;p&gt;If VibeScan had found nothing, I would have been suspicious — not proud. Every non-trivial codebase has some security debt. Mine does. The question isn't whether the tool finds issues; it's whether it finds &lt;strong&gt;real&lt;/strong&gt; issues (not noise), explains them &lt;strong&gt;clearly&lt;/strong&gt; (not CVE-jargon), and gives &lt;strong&gt;specific&lt;/strong&gt; fixes (not "review this").&lt;/p&gt;

&lt;p&gt;The findings above: one real bug, one over-reported but accurate concern. Both had concrete file paths and line numbers. Both had copy-paste fixes. Both took under 15 minutes to validate + address or triage.&lt;/p&gt;

&lt;p&gt;That's the experience I promise buyers. Running it against my own code is the most honest unit test of that promise I can run.&lt;/p&gt;




&lt;h2&gt;
  
  
  The wider pattern
&lt;/h2&gt;

&lt;p&gt;If you ship AI-scaffolded code and you're worried there's a leaky URL token, a committed secret, an unauthenticated function, or a misconfigured RLS policy in there — there probably is. Every codebase has some. The question is whether you know where, and whether you've prioritized the ones that actually matter.&lt;/p&gt;

&lt;p&gt;If you want the same treatment for your app, VibeScan is $49 one-time at &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;systag.gumroad.com/l/vibescan&lt;/a&gt;. PDF in ~10 minutes, 7-day refund if the report isn't useful. Or there's a &lt;a href="https://systagproject.github.io/vibescan-landing/SAMPLE_REPORT.pdf" rel="noopener noreferrer"&gt;public sample&lt;/a&gt; if you want to see the format before committing.&lt;/p&gt;

&lt;p&gt;Either way — check your own token-passing code today. &lt;code&gt;?token=&lt;/code&gt; in a URL is the kind of bug that sits quietly for years until the logs leak.&lt;/p&gt;

&lt;p&gt;— Michael&lt;/p&gt;

</description>
      <category>security</category>
      <category>selfreview</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Supabase Edge Function Probably Has No Auth. 8 Out of 9 Vibe-Coded Apps I Scanned This Week Didn't.</title>
      <dc:creator>SystAgProject</dc:creator>
      <pubDate>Mon, 20 Apr 2026 06:50:37 +0000</pubDate>
      <link>https://forem.com/systagproject/your-supabase-edge-function-probably-has-no-auth-8-out-of-9-vibe-coded-apps-i-scanned-this-week-4lb1</link>
      <guid>https://forem.com/systagproject/your-supabase-edge-function-probably-has-no-auth-8-out-of-9-vibe-coded-apps-i-scanned-this-week-4lb1</guid>
      <description>&lt;p&gt;I've been running &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;VibeScan&lt;/a&gt; — my security audit for AI-generated SaaS — against public Lovable / Bolt / v0 apps all week. I wrote up &lt;a href="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"&gt;the 5 patterns I keep finding&lt;/a&gt; yesterday.&lt;/p&gt;

&lt;p&gt;This post is about the &lt;strong&gt;single most common one&lt;/strong&gt;: Supabase Edge Functions (or Vercel Functions, or Cloudflare Workers — the pattern is identical) deployed without any auth verification.&lt;/p&gt;

&lt;p&gt;Hit rate in my corpus: &lt;strong&gt;8 out of 9.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is not "the app is missing MFA". It's not "the password policy is weak". It is: &lt;strong&gt;anyone on the internet who finds or guesses your function's URL can invoke it as if they were a logged-in user.&lt;/strong&gt; With your service-role permissions. For as long as they want.&lt;/p&gt;

&lt;p&gt;Here's why it happens, what the blast radius looks like, and the ~20 lines of code that fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick refresher: what Edge Functions actually are
&lt;/h2&gt;

&lt;p&gt;If you've only touched client-side React in Lovable or Bolt, you may not have thought much about what happens when you call &lt;code&gt;supabase.functions.invoke('process-payment', ...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What happens is: Supabase proxies the call to a &lt;strong&gt;public HTTPS endpoint&lt;/strong&gt; — something like &lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/process-payment&lt;/code&gt;. That URL is reachable from anywhere. Your React app hits it with a &lt;code&gt;fetch()&lt;/code&gt; behind the scenes. The function executes in a Deno runtime, with access to your service-role key and your whole Postgres database.&lt;/p&gt;

&lt;p&gt;Same shape for Vercel (&lt;code&gt;/api/whatever.ts&lt;/code&gt;), Cloudflare Workers, Netlify Functions. Public URL → server-side code → your secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only thing standing between an attacker and that URL is whatever auth check your function code performs before doing work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's what's missing in 8 out of 9 AI-scaffolded apps.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Here's the kind of code I keep finding. This is anonymized but structurally identical to what I saw in a healthcare patient-intake app, a payments-adjacent app, and an AI-generation app all this week:&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;// supabase/functions/process-payment/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;serve&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;https://deno.land/std@0.190.0/http/server.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;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@2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card_token&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;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="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// full DB access&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// ... charges card via Stripe, inserts into orders, etc.&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;orders&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="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;Read the first line carefully: &lt;code&gt;const { user_id, amount } = await req.json()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The user_id is coming from the request body.&lt;/strong&gt; Not from an authenticated session. Not verified against anything. I — a random person on the internet with &lt;code&gt;curl&lt;/code&gt; — can POST to this endpoint with any &lt;code&gt;user_id&lt;/code&gt; I want and any &lt;code&gt;amount&lt;/code&gt; I want, and the function will insert an order for that user at that amount, using the service-role key which bypasses RLS entirely.&lt;/p&gt;

&lt;p&gt;I can insert fake orders in other users' names. I can set amount to $0 and run the function a million times and exhaust your Supabase egress quota. I can set amount to $1,000,000 and corrupt your accounting. If the function calls Stripe — I can spam Stripe until your account is flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The function runs. No auth. Full access.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why AI scaffolding misses it
&lt;/h2&gt;

&lt;p&gt;Three reasons, all fixable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Supabase's function template.&lt;/strong&gt; &lt;code&gt;supabase functions new&lt;/code&gt; scaffolds a bare handler. It doesn't include an auth block. The "Hello World" in the docs doesn't either. If an AI model was trained on the template or the Hello World, it will copy that pattern — and the pattern has no auth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Row-Level Security illusion.&lt;/strong&gt; If the developer has RLS policies on tables, they (reasonably) think "the database handles auth". It does — but only when you query it with the &lt;em&gt;anonymous&lt;/em&gt; key and the user's JWT. The moment you use the service-role key (which Edge Functions do by default, because you often need to do admin work), &lt;strong&gt;RLS is bypassed.&lt;/strong&gt; The DB no longer checks who the caller is. The function has to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Client-side auth looks enough.&lt;/strong&gt; The React app requires login before it even shows the "pay" button. So in the developer's head, the flow is "logged-in user → click button → call function → done". They forget that the function's URL is a public artifact and anyone with it can skip the React app entirely.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The fix — about 20 lines
&lt;/h2&gt;

&lt;p&gt;You need two things in every single Edge Function that isn't a public webhook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify the caller's JWT&lt;/strong&gt; — extract it from the &lt;code&gt;Authorization&lt;/code&gt; header, ask Supabase to validate it, get back a &lt;code&gt;user&lt;/code&gt; object (or reject).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use that &lt;code&gt;user.id&lt;/code&gt; as the authoritative source&lt;/strong&gt; for any identity the function does work on — not the request body.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the minimal 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="c1"&gt;// supabase/functions/process-payment/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;serve&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;https://deno.land/std@0.190.0/http/server.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;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@2&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="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;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &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;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&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;ok&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Verify the caller's JWT. The Authorization header is forwarded by&lt;/span&gt;
  &lt;span class="c1"&gt;//    supabase.functions.invoke() automatically; we just need to validate it.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&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="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;authHeader&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;missing auth&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&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;supabaseUserClient&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_ANON_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="c1"&gt;// anon key, with user's JWT attached&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;authHeader&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="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="nx"&gt;error&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;supabaseUserClient&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="nx"&gt;error&lt;/span&gt; &lt;span class="o"&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;invalid token&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Now you can trust user.id. Do NOT trust request-body user_id.&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;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card_token&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;// deliberately ignoring any user_id in body&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;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;// 3. For the actual write, decide: admin work (service role) or user-scoped (anon + JWT)?&lt;/span&gt;
  &lt;span class="c1"&gt;//    Inserting an order FOR the verified user is fine either way, but service-role&lt;/span&gt;
  &lt;span class="c1"&gt;//    is the safe choice if you also need to write to admin-only tables.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&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="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="nx"&gt;supabaseAdmin&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;orders&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;user_id&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="nx"&gt;amount&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&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;application/json&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;p&gt;That's it. About 20 lines of added logic. &lt;code&gt;supabase.functions.invoke&lt;/code&gt; on the client automatically forwards the user's &lt;code&gt;Authorization&lt;/code&gt; header, so you don't have to change your React code at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key things to notice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We use two clients: one with the &lt;strong&gt;anon key + user's JWT&lt;/strong&gt; for identity verification (&lt;code&gt;getUser()&lt;/code&gt; call), another with the &lt;strong&gt;service-role key&lt;/strong&gt; for the actual DB write.&lt;/li&gt;
&lt;li&gt;We ignore any &lt;code&gt;user_id&lt;/code&gt; in the request body. The authoritative identity is &lt;code&gt;user.id&lt;/code&gt; from the verified token.&lt;/li&gt;
&lt;li&gt;The function returns 401 for missing or invalid auth. No information leakage about whether a user exists.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Testing the fix
&lt;/h2&gt;

&lt;p&gt;Before you ship, verify the fix actually works with three &lt;code&gt;curl&lt;/code&gt; calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. No auth header → should return 401&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/process-payment'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"amount": 50}'&lt;/span&gt;
&lt;span class="c"&gt;# → 401 "missing auth"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Bogus token → should return 401&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/process-payment'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer not-a-real-jwt"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"amount": 50}'&lt;/span&gt;
&lt;span class="c"&gt;# → 401 "invalid token"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Valid token (get one by logging in on your React app and copying the access_token from localStorage)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/process-payment'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;real-jwt&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"amount": 50}'&lt;/span&gt;
&lt;span class="c"&gt;# → 200 {"ok": true}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this before deploying. If test 1 or 2 return 200, the auth block is misplaced and the function is still open.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: the other things you want on public functions
&lt;/h2&gt;

&lt;p&gt;Once auth is in place, two more cheap upgrades are worth doing the same afternoon:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting.&lt;/strong&gt; Even with auth, a malicious (or simply buggy) authenticated user can spam your function. A 5-line Upstash Redis check:&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;npm:@upstash/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;Redis&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:@upstash/redis&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="nx"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&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;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60 s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// 20 requests per minute per user&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="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 limit&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;Upstash's free tier is plenty for most side projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cron-only functions.&lt;/strong&gt; If a function is meant to run only from a scheduled job (e.g. &lt;code&gt;daily-cleanup&lt;/code&gt;), don't require JWT auth — require a specific secret header instead, so the cron job can call it but random authenticated users cannot:&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;cronSecret&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;X-Cron-Secret&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;cronSecret&lt;/span&gt; &lt;span class="o"&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;CRON_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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;X-Cron-Secret&lt;/code&gt; on your Supabase cron job, and you have a scoped-access function that users can't invoke at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The meta point
&lt;/h2&gt;

&lt;p&gt;Every one of these fixes is &lt;strong&gt;cheap&lt;/strong&gt;. Adding JWT verification is 20 lines. Rate limiting is 5 more. A cron-secret check is 3. Total: under 30 lines of code per function.&lt;/p&gt;

&lt;p&gt;The problem isn't the difficulty. It's the invisibility. The function works fine without these blocks — your React app happily calls it, orders get inserted, payments go through. Nothing looks broken until someone curls your endpoint and discovers they have superuser access.&lt;/p&gt;

&lt;p&gt;AI scaffolding won't add these for you by default. If you're shipping on Lovable, Bolt, v0, or anything else that scaffolds Supabase + Edge Functions for you — &lt;strong&gt;every function you deploy needs this pattern manually added.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  If you want your whole stack checked
&lt;/h2&gt;

&lt;p&gt;This is one of 150+ patterns I scan for. If you have a Supabase + Vercel/Netlify AI-coded app and you'd rather I grep every single function + policy + endpoint for this class of issue, VibeScan is $49 one-time at &lt;a href="https://systag.gumroad.com/l/vibescan" rel="noopener noreferrer"&gt;systag.gumroad.com/l/vibescan&lt;/a&gt;. PDF delivered in ~10 minutes, severity-graded, every finding with the exact file, line, and copy-paste fix. 7-day refund if the report isn't useful.&lt;/p&gt;

&lt;p&gt;Otherwise — the snippet above is the highest-impact thing you can add to a vibe-coded SaaS tonight. Harden every function, and you've removed the single most common attack surface in the AI-scaffolded stack.&lt;/p&gt;

&lt;p&gt;— Michael&lt;/p&gt;

</description>
      <category>security</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <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://forem.com/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://forem.com/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://forem.com/systagproject/your-first-supabase-rls-policy-without-exposing-your-whole-database-4jam</link>
      <guid>https://forem.com/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://forem.com/systagproject/the-12-security-issues-i-keep-finding-in-vibe-coded-apps-lovable-bolt-v0-786</link>
      <guid>https://forem.com/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://forem.com/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://forem.com/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>
