<?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: Ujjval Bhatti</title>
    <description>The latest articles on DEV Community by Ujjval Bhatti (@ujjvalbhatti).</description>
    <link>https://dev.to/ujjvalbhatti</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%2F4135044%2F7c70159b-d9ec-4f0b-835f-b57c7125c55c.jpg</url>
      <title>DEV Community: Ujjval Bhatti</title>
      <link>https://dev.to/ujjvalbhatti</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ujjvalbhatti"/>
    <language>en</language>
    <item>
      <title>Your AI-built app works. That doesn't mean it's safe to take money on.</title>
      <dc:creator>Ujjval Bhatti</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:53:24 +0000</pubDate>
      <link>https://dev.to/ujjvalbhatti/your-ai-built-app-works-that-doesnt-mean-its-safe-to-take-money-on-83f</link>
      <guid>https://dev.to/ujjvalbhatti/your-ai-built-app-works-that-doesnt-mean-its-safe-to-take-money-on-83f</guid>
      <description>&lt;p&gt;Lovable, Bolt, Replit, v0 and Cursor will get you from an idea to a deployed, working product in a weekend. That used to take a month. This is genuinely good.&lt;/p&gt;

&lt;p&gt;But they optimise for &lt;strong&gt;it renders&lt;/strong&gt;. Taking money needs &lt;strong&gt;it holds&lt;/strong&gt;. Those are different bars, and the gap between them is where I spend my working life.&lt;/p&gt;

&lt;p&gt;Here are the seven checks I run in the first hour of every rescue. Run them on your own app right now - all of them are free and most take under a minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Search your client bundle for secrets
&lt;/h2&gt;

&lt;p&gt;Open your live site, view source, open the JS bundles it loads, and search for &lt;code&gt;key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A client bundle is public. Whatever your dashboard calls it, a key shipped to the browser belongs to everyone who visits.&lt;/p&gt;

&lt;p&gt;The one that hurts most is Supabase's &lt;code&gt;service_role&lt;/code&gt; token, because it looks exactly like the &lt;code&gt;anon&lt;/code&gt; token that is &lt;em&gt;supposed&lt;/em&gt; to be there. Both are JWTs. Decode the payload and look:&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;payload&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;Buffer&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="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// 'anon'          -&amp;gt; fine, this one is meant to be public&lt;/span&gt;
&lt;span class="c1"&gt;// 'service_role'  -&amp;gt; this bypasses every row-level security policy you have&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;service_role&lt;/code&gt;, rotate it today and move it behind your own server route.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. curl your own API with no auth token
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://yourapp.com/api/orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns data, your authentication is UI-only: the login screen hides the button, not the endpoint. This is the single most common hole I find, and it is invisible from the browser because your frontend always sends the token.&lt;/p&gt;

&lt;p&gt;The fix is boring and unavoidable - check the session &lt;strong&gt;inside&lt;/strong&gt; every route, not in the component that renders the link.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Check row-level security on every table, not the ones you tested
&lt;/h2&gt;

&lt;p&gt;AI tools scaffold tables fast, and RLS is off by default. You tested &lt;code&gt;profiles&lt;/code&gt;. Did you test &lt;code&gt;invoices&lt;/code&gt;, the one you added at 1am three weeks later?&lt;/p&gt;

&lt;p&gt;One table without RLS means any logged-in user reads every row in it, including other customers' rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verify your webhook signatures
&lt;/h2&gt;

&lt;p&gt;If your &lt;code&gt;/api/webhooks/stripe&lt;/code&gt; route trusts the request body, anyone who discovers that URL can POST it and mark an order paid. You will ship goods for money that never arrived.&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;crypto&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:crypto&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&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;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// the RAW body, not the parsed JSON&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;Buffer&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="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;Buffer&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="nx"&gt;signatureFromHeader&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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad 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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things people get wrong here: comparing with &lt;code&gt;===&lt;/code&gt; instead of a timing-safe compare, and hashing the parsed body instead of the raw one.&lt;/p&gt;

&lt;p&gt;I got tired of the ngrok-and-dashboard loop needed to test this, so I published &lt;a href="https://www.npmjs.com/package/razorpay-trigger" rel="noopener noreferrer"&gt;razorpay-trigger&lt;/a&gt; - a zero-dependency CLI that fires correctly signed webhooks at localhost so your handler is testable in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Does a failed payment roll the order back?
&lt;/h2&gt;

&lt;p&gt;Create an order, force the payment to fail, then look in your database. If there is a row sitting there marked paid, you have a reconciliation problem that grows quietly until the day you check your bank balance against your dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Rate limit three things
&lt;/h2&gt;

&lt;p&gt;Signup, password reset, and above all the LLM call that bills you per token. An unmetered AI endpoint is somebody else's free API, paid for with your card.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Run one test before deploy
&lt;/h2&gt;

&lt;p&gt;Not a test suite. One test. One is infinitely more than zero, and it is the thing that stops the 2am regression that breaks checkout.&lt;/p&gt;




&lt;h2&gt;
  
  
  I automated the four a machine can actually check
&lt;/h2&gt;

&lt;p&gt;Checks 1 and 2 are tedious by hand, and nobody does them twice. So I built a scanner:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://vibecodeaudit.vercel.app" rel="noopener noreferrer"&gt;vibecodeaudit.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paste a URL. In about 20 seconds it fetches the public page and the same-origin scripts that page already tells a browser to load, then reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;secret keys in the client bundle - Stripe, OpenAI, Anthropic, AWS, Google, GitHub, Slack, SendGrid, Razorpay, private key blocks, and Supabase &lt;code&gt;service_role&lt;/code&gt; JWTs decoded and role-checked&lt;/li&gt;
&lt;li&gt;publicly reachable source maps&lt;/li&gt;
&lt;li&gt;missing security headers (CSP, frame protection, HSTS, nosniff, referrer policy)&lt;/li&gt;
&lt;li&gt;cookies without HttpOnly or Secure&lt;/li&gt;
&lt;li&gt;direct Supabase or Firebase access from the browser&lt;/li&gt;
&lt;li&gt;which tool built the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is free, needs no signup, and stores nothing. It is also strictly passive: it makes the same requests any visitor's browser makes, never probes private routes, never attempts a login, and never writes anything.&lt;/p&gt;

&lt;p&gt;A clean score means nothing reachable from outside was found. It does &lt;strong&gt;not&lt;/strong&gt; mean your app is secure - checks 2 through 7 above live behind your login, and no external scanner can see them.&lt;/p&gt;




&lt;p&gt;None of this is an argument against building with AI. It is an argument for one hour of review before the first real payment lands.&lt;/p&gt;

&lt;p&gt;If you want that hour done properly on your app, I do it for a flat $149 with the report back in 48 hours - I'm &lt;a href="https://x.com/Ujjvalbhatti007" rel="noopener noreferrer"&gt;@Ujjvalbhatti007&lt;/a&gt;.&lt;/p&gt;

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