<?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: kh.vibecoding</title>
    <description>The latest articles on DEV Community by kh.vibecoding (@neostorm112boop).</description>
    <link>https://dev.to/neostorm112boop</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%2F4119779%2F8fb8693c-24f7-428d-8bc7-59daa4246451.png</url>
      <title>DEV Community: kh.vibecoding</title>
      <link>https://dev.to/neostorm112boop</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/neostorm112boop"/>
    <language>en</language>
    <item>
      <title>Five Layers of Protection for Payments and AI Requests</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:06:05 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/five-layers-of-protection-for-payments-and-ai-requests-6o3</link>
      <guid>https://dev.to/neostorm112boop/five-layers-of-protection-for-payments-and-ai-requests-6o3</guid>
      <description>&lt;p&gt;Ask an assistant to "build payment processing" and you get code that works. Click the button, pay, get access. Tests pass, the demo looks convincing to the client. Then someone opens devtools and pays a dollar instead of a hundred. Here is what an AI assistant leaves broken in payment handling by default, on a real client project, and what we build around payments and AI requests so it does not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole one: the price comes from the client
&lt;/h2&gt;

&lt;p&gt;Here is what gets generated by default if you simply ask for "payment processing":&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;// do not do this&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;/checkout&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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="c1"&gt;// amount came from the browser&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="nx"&gt;psp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&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;url&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;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;It looks logical enough: the frontend knows the price, so it sends it. But the frontend runs on the buyer's machine, and it takes one line in the browser console to edit. &lt;code&gt;amount: 10000&lt;/code&gt; becomes &lt;code&gt;amount: 100&lt;/code&gt;, the payment provider happily charges a dollar, and the product ships.&lt;/p&gt;

&lt;p&gt;The fix: the client sends only the id of what it is buying, and the server looks up the price itself.&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="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;/checkout&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;product&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;products&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="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;product_id&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;product&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;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// record the order before calling the PSP: we reconcile the webhook against it later&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&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;orders&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;user_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;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;product&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;amount_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// price comes from the database only&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;psp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;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;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&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="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;url&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;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;Three lines of difference. But as long as the amount comes from &lt;code&gt;req.body&lt;/code&gt;, any checks further down the code are pointless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole two: the "thank you" page confirms the payment
&lt;/h2&gt;

&lt;p&gt;The second common pattern: the user returns from the payment provider to &lt;code&gt;/success&lt;/code&gt;, and access is granted right there. This breaks both ways. Close the tab right after the charge and the money is gone but access never gets granted, and you never find out. Or open &lt;code&gt;/success&lt;/code&gt; directly, skip the payment entirely, and get access for free.&lt;/p&gt;

&lt;p&gt;The only source of truth for a payment is the provider's webhook. And receiving it is not enough — you have to verify it: signature, timestamp, idempotency, and the order amount.&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// express.raw is essential here: the signature is computed over the raw body.&lt;/span&gt;
&lt;span class="c1"&gt;// After JSON.parse and re-serialization the bytes are already different.&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;/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="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;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="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;X-Signature&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;ts&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="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;X-Timestamp&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="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ts&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="c1"&gt;// 1. Replay: a valid webhook intercepted once should not be replayable tomorrow&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ts&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;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="s2"&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;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="c1"&gt;// 2. Constant-time comparison. Plain === leaks timing information:&lt;/span&gt;
  &lt;span class="c1"&gt;//    the signature can be brute-forced byte by byte from the rejection speed.&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;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&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;amp;&amp;amp;&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;sig&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="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="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;403&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;event&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;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="c1"&gt;// 3. Idempotency: the provider retries until it gets a 200.&lt;/span&gt;
  &lt;span class="c1"&gt;//    Without this check, one payment extends a subscription three times over.&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="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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;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="c1"&gt;// 4. Reconcile the amount against our own order, never trust the event alone&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&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;orders&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&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;order&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_cents&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;amount_cents&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&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;currency&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;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment_mismatch&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;event&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;409&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;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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;await&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, an assistant does, at best, the first of these five checks — signature verification. Idempotency, constant-time comparison, and amount reconciliation have to be asked for explicitly, one by one. Neither hole gets caught by ordinary testing: tests check "paid → got access" and "did not pay → no access", and an attacker is interested in the third path nobody on the review side thought to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the model defaults to this
&lt;/h2&gt;

&lt;p&gt;I am not a senior engineer — I build products with AI, and the first thing that taught me is that the most dangerous thing is not bad code, it is code that looks like it works.&lt;/p&gt;

&lt;p&gt;A model reproduces the most common pattern from what it was trained on, and in tutorials and examples the check is almost always on the client — it is shorter and easier to demonstrate. It implements what is visible in the interface, because the interface is what you described to it.&lt;/p&gt;

&lt;p&gt;So for critical features — payments, access, other people's data — my first request is never about code:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Don't write code yet. Look at the integration docs, the security standards, and the common vulnerabilities (especially price tampering and payment validation). Give me 2–3 ways to do this safely, list the trade-offs of each, and tell me which you recommend and why."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The holes get closed before any code exists — reworking the architecture afterward costs ten times as much. And the side effect turned out to matter more than the main one: you start actually understanding how your own product works under the hood, and you choose the approach deliberately. You do not need this for every button. For payments, it is not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attacking your own product before handover
&lt;/h2&gt;

&lt;p&gt;Before handing off a project with payments, I open a clean session — not the context the code was written in — give the agents access to the result, and one task: bypass the payment.&lt;/p&gt;

&lt;p&gt;The clean session is essential. An agent that knows "how it was meant to work" defends the design and explains why it is correct. An agent that only sees the code looks for a way to fool it.&lt;/p&gt;

&lt;p&gt;That time, they found a bypass the first audit had missed.&lt;/p&gt;

&lt;p&gt;On backups, separately: the habit started after a day an AI agent, tweaking something minor on a server, took down a client's live site. A snapshot taken before the work started saved it — three minutes, and the site was back. Since then, a backup is the first step of any task, no exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we run around payments
&lt;/h2&gt;

&lt;p&gt;Full protection does not exist. The goal is different: make an attack cost more than whatever it could gain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log everything.&lt;/strong&gt; Every request, every operation. Looks paranoid right up until the first incident review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatically block suspicious IPs&lt;/strong&gt; — bots, spammers, odd request patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate-limit spend per account&lt;/strong&gt; — a sliding window on request count and on money spent:&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;// Count money as well as requests: 10 expensive calls&lt;/span&gt;
&lt;span class="c1"&gt;// hit the wallet harder than 1,000 cheap ones.&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;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;costCents&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;WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&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="s2"&gt;`spend:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accountId&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zremrangebyscore&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;WINDOW&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zadd&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="nx"&gt;now&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;span class="nx"&gt;now&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;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&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;span class="nx"&gt;costCents&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&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="nx"&gt;WINDOW&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;entries&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zrange&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;spent&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;2&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&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;limits&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="nx"&gt;accountId&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;requests&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requests_per_hour&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cents_per_hour&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;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anomaly&lt;/span&gt;&lt;span class="dl"&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;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_blocked&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;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spent&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;false&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="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;This fires in two cases: either someone found a genuine vulnerability, or a stranger is running requests against a paid AI API on someone else's account — they found an endpoint that proxies calls to the model past the interface and its limits, and is using it as a free ChatGPT. Either way, the response is the same: stop first, investigate after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time alerts.&lt;/strong&gt; Not "find out from the logs a week later" — see it now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A kill switch.&lt;/strong&gt; A script that cuts every external connection: no data, no API, and a notification to me.&lt;/p&gt;

&lt;p&gt;It sounds dramatic for a product built by one person. But over-engineering this ten times over is still cheaper than getting it wrong once with a client's money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;AI agents do not replace a pentest. They do not see the full infrastructure, do not know the business context, and confidently claim there is nothing wrong when they simply found nothing — every finding still needs a human check. It is a cheap first filter, not a security audit.&lt;/p&gt;

&lt;p&gt;And the code above does not make a product unbreakable. It closes two specific places where mistakes happen most often.&lt;/p&gt;

&lt;p&gt;More on how we build this at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;hikmah-labs.dev/en/&lt;/a&gt; — a studio that builds web apps, bots, and AI agents, with payment and access security handled as a first-class part of the build, not an afterthought.&lt;/p&gt;

</description>
      <category>security</category>
      <category>payments</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Cross-Posting: Why One Post Does Not Fit Every Platform</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:03:38 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/ai-cross-posting-why-one-post-does-not-fit-every-platform-37m0</link>
      <guid>https://dev.to/neostorm112boop/ai-cross-posting-why-one-post-does-not-fit-every-platform-37m0</guid>
      <description>&lt;p&gt;We are building PostPilot, a B2B cross-posting platform: a post is written once and goes out on every connected platform, with AI adapting the text to each platform's format. The product is live, currently in stage three of three, with several platforms already connected. Here is what "adapting to a platform" actually means, and why simply duplicating a post does not solve the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "one post everywhere" does not work
&lt;/h2&gt;

&lt;p&gt;The first idea anyone has about cross-posting is to copy a post and push it to every channel with one button. It is the simplest technical solution, which is exactly why it delivers so little. Every platform reads differently: some show the whole text at once, some show only the first lines before the reader decides whether to keep going, some treat the headline as a separate interface element rather than the first line of the body. The same post, dropped unchanged into every format, either gets cut in the wrong place or reads as foreign on a platform it was never written for.&lt;/p&gt;

&lt;p&gt;Duplication solves "the text physically exists on every platform." It does not solve "the text gets read." Those are different problems, and a business paying to maintain a presence across several channels needs the second one solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes during adaptation
&lt;/h2&gt;

&lt;p&gt;In PostPilot, the same source post does not go out unchanged — AI reworks it for the format of each specific channel. Our project card states this directly: headlines and framing change per platform. That means the headline for a platform where it exists as a separate element does not match the first line of the text a reader sees on a platform with no headlines at all. Framing is how the text is built — what comes first, what gets pulled forward, and what can be cut when a platform's format is not built for long text.&lt;/p&gt;

&lt;p&gt;We want to be precise about what we are claiming here and what we are not. We will not list a specific set of technical adaptation parameters — character length, tone, markup — beyond what the project card states: the headline and the framing change for each platform. That alone is a non-trivial problem: the system has to understand the format of every connected platform and rewrite the text for it while keeping the author's point intact, rather than turning the adaptation into a different text with a different meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a publishing queue, not just a broadcast
&lt;/h2&gt;

&lt;p&gt;Cross-posting to one channel is a "publish" function. Cross-posting to several platforms is a process: a post has to go through adaptation for each platform, join a publishing queue, and go out at the right time on each of them, without losing a copy or duplicating one. If any one of those steps is built carelessly, the outcome is predictable — a post goes out late on one platform, never goes out on another, and goes out twice on a third. For a B2B client paying specifically to avoid manual publishing, any of those failures undermines the whole product, regardless of how good the text adaptation itself is, if the post never actually reached the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture built for growth
&lt;/h2&gt;

&lt;p&gt;The project card states separately that the architecture is built to scale with volume. For a B2B cross-posting platform that is a real requirement, not a throwaway line. A client connects platforms gradually rather than all at once — starting with one or two, confirming the text comes out the way it should, and only then adding the next ones. If the architecture is tightly bound to a fixed set of platforms or to current publishing volume, every expansion becomes a rebuild instead of a configuration change. We built the architecture so that connecting a new platform and growing the number of posts do not require rebuilding the system from scratch — that is what we can confirm on our side, without disclosing the client's specific technical implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a B2B client actually gets
&lt;/h2&gt;

&lt;p&gt;The practical result for a business maintaining a presence across several platforms: a post is written once instead of being rewritten by hand for each platform separately. That saves the editor's or marketer's time otherwise spent fitting the same message into different formats. Several platforms are already connected and working — this is not a single-platform prototype but a system built for a company's real workflow, where a multi-channel presence is not a one-off campaign but a permanent part of talking to an audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you do not need this
&lt;/h2&gt;

&lt;p&gt;A product like PostPilot solves a specific problem, and it has boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single platform.&lt;/strong&gt; If a company maintains a presence on only one channel, the task of "adapt to several formats" simply does not exist. Cross-posting with adaptation solves the problem of multiple platforms — where there is only one, there is nothing here worth paying for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrequent posts.&lt;/strong&gt; If posts go out once every week or two, manually adapting a post per platform takes 15–20 minutes — not enough time to justify paying for automation and maintaining platform integrations for that volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content that needs a hand-crafted presentation.&lt;/strong&gt; Some formats are not primarily about text: posts with complex layout, an authorial voice that needs line-by-line control, material where any automatic change to framing reads to the audience as a loss of authorship. For content like that, an adaptation system gets in the way rather than helping — manual publishing or an editor who adjusts the text per platform themselves works better.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you fall into one of these three cases, the honest advice is not to build a cross-posting platform — solve it more simply, with a single channel, occasional manual posting, or a human editor with full control.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we work on projects like this
&lt;/h2&gt;

&lt;p&gt;AI agents and integrations at this level start from $1,400, usually 3–5 weeks, with the price fixed in the technical spec before the start and no increases along the way. If the scope and architecture are not obvious upfront, there is a discovery phase: $170, 3–5 days, a written report with risks and an exact price — the amount counts toward the project. We work in stages, each ending with a demo of a working part of the system, 50% upfront, and 90 days of free fixes after handover. Project data is under NDA by default.&lt;/p&gt;

&lt;p&gt;We are Hikmah Labs, a small studio building AI agents, automation and web products for businesses. More about us at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;https://hikmah-labs.dev/en/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>ProxyKey MCP: Giving an AI Agent API Access Without Handing Over a Key</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:00:45 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/proxykey-mcp-giving-an-ai-agent-api-access-without-handing-over-a-key-1ehi</link>
      <guid>https://dev.to/neostorm112boop/proxykey-mcp-giving-an-ai-agent-api-access-without-handing-over-a-key-1ehi</guid>
      <description>&lt;p&gt;We already wrote about why a credential proxy exists and why "zero-knowledge" is impossible for one by design — that proxy is ProxyKey, our own project. This post is the practical follow-up: how to connect Claude Code or Cursor to ProxyKey over MCP, what the 13 available tools are, why "read the key" is deliberately not one of them, and how the scenario we built all of this for actually plays out — an agent deploying a bot that doesn't have a token yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an agent needs MCP to a vault, not just a key in .env
&lt;/h2&gt;

&lt;p&gt;An agent like Claude Code or Cursor lays out environment variables, writes configs, and sometimes logs what it's doing. Anything that enters a model's context has to be treated as published: context gets logged, traced, and can be pulled out through prompt injection. Handing that agent a live OpenAI key or a Telegram bot token is functionally the same as handing it to a random script on the internet.&lt;/p&gt;

&lt;p&gt;The ProxyKey MCP server solves this at the protocol level, not with a policy the agent is trusted to follow. The agent gets a tool, not a secret. None of the 13 methods has an operation that returns a key's value. The agent can create, revoke, and rotate passes, and inspect limits and request logs — but it cannot read the original, because that endpoint simply doesn't exist in the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting: Claude Code and Cursor
&lt;/h2&gt;

&lt;p&gt;The first step is a human one: sign in at app.proxykey.org (GitHub OAuth or a magic link, free), open the MCP section, and create a token in the form &lt;code&gt;mcp_…&lt;/code&gt;. Only that token goes to the agent — never the real provider keys.&lt;/p&gt;

&lt;p&gt;For Claude Code, one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http proxykey https://mcp.proxykey.org/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer mcp_YOUR_TOKEN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Cursor and Claude Desktop, an entry in &lt;code&gt;mcp.json&lt;/code&gt;:&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;"mcpServers"&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;"proxykey"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.proxykey.org/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer mcp_YOUR_TOKEN"&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;From there the agent sees ProxyKey's tools like any other MCP tools and can call them on its own, without a human in the loop on every step.&lt;/p&gt;

&lt;h2&gt;
  
  
  13 tools — and why "read the key" is not one of them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Catalogue and metadata&lt;/strong&gt;: &lt;code&gt;list_providers&lt;/code&gt; lists providers and their auth model. &lt;code&gt;list_secrets&lt;/code&gt; shows stored keys, metadata only, never values. &lt;code&gt;get_manual_secret_setup&lt;/code&gt; returns a link for a human to enter the real key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass management&lt;/strong&gt;: &lt;code&gt;create_pass&lt;/code&gt;, &lt;code&gt;create_pending_pass&lt;/code&gt;, &lt;code&gt;update_pass&lt;/code&gt;, &lt;code&gt;rotate_pass&lt;/code&gt;, &lt;code&gt;revoke_pass&lt;/code&gt;, &lt;code&gt;delete_pass&lt;/code&gt;, &lt;code&gt;rebind_pass_ip&lt;/code&gt; — the full lifecycle of a virtual token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: &lt;code&gt;list_passes&lt;/code&gt;, &lt;code&gt;get_pass_logs&lt;/code&gt;, &lt;code&gt;get_pass_stats&lt;/code&gt; — the agent sees each pass's status, limits, IP binding, and request history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the 13 operations has a parameter that returns a secret's value. This isn't "the agent has agreed not to look" — that capability simply isn't in the API contract. For the same reason, the MCP surface can't turn on request-body logging either — that stays human-only in the panel, because otherwise a key could be reconstructed indirectly through the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pending-secret scenario: an agent deploys a bot without a token
&lt;/h2&gt;

&lt;p&gt;This is the exact reason we built all of this. A common situation: an agent is deploying a Telegram bot, writing the code, wiring up the webhook — but there's no BotFather token yet, because the bot hasn't been created.&lt;/p&gt;

&lt;p&gt;Instead of stalling and waiting on a human, the agent calls &lt;code&gt;create_pending_pass&lt;/code&gt;. The pass (&lt;code&gt;vlt_…&lt;/code&gt;) is issued immediately and can go straight into the bot's config — but proxying real traffic is blocked with the status &lt;code&gt;original_key_required&lt;/code&gt; until the secret is filled in. The agent then calls &lt;code&gt;get_manual_secret_setup&lt;/code&gt; and hands the resulting link to a human.&lt;/p&gt;

&lt;p&gt;The human opens the panel, pastes in the real token once, and the pass activates automatically — no second call from the agent required. The bot comes alive. At no point did the agent see the secret's value; it went through the panel, not through the model's context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a human sees in the panel
&lt;/h2&gt;

&lt;p&gt;The panel is the only place a real key's value ever lands — the form used for initial setup or for filling in a pending secret. From there the interface shows the list of stored secrets (metadata only, no values), the list of passes with their status, IP binding and limits, and a per-pass request log — metadata, with no authorization headers and no key material.&lt;/p&gt;

&lt;p&gt;The split is simple: anything that could expose a secret's value is human-only, through the panel. Anything the agent needs for day-to-day work — issuing, revoking, rotating, monitoring — is available over MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proxy call itself
&lt;/h2&gt;

&lt;p&gt;Once a pass is issued, the application or agent points at the proxy instead of the provider — only the host and the key change; the path and body stay the same:&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&lt;/span&gt;
curl https://api.openai.com/v1/chat/completions &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk-..."&lt;/span&gt;
&lt;span class="c"&gt;# after&lt;/span&gt;
curl https://api.proxykey.org/p/openai/v1/chat/completions &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer vlt_openai_..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming (SSE), request bodies, and headers pass through unchanged. Telegram bots keep their usual URL shape: &lt;code&gt;/p/telegram-bot/&amp;lt;pass&amp;gt;/getMe&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A limitation worth stating plainly
&lt;/h2&gt;

&lt;p&gt;A hosted proxy cannot be zero-knowledge by design: to put a key into a request to the provider, the proxy has to decrypt it in memory at the moment it handles that request. Which means a process with full access — and, by extension, the service operator — can in principle obtain the plaintext. That isn't a bug specific to ProxyKey; it's a property of every hosted solution in this category. We covered this in detail on the &lt;a href="https://proxykey.org/en/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt;: what the encryption actually protects against (a database leak, a stolen backup, a log leak) and what it doesn't (a fully compromised server). The agent's MCP access doesn't add new risk on top of that — if anything it's more restricted than a human's access through the panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you don't need this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You have one key and one consumer.&lt;/strong&gt; If a key is used in a single place you fully control and no agent ever touches it, a proxy just adds a point of failure with no real upside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your threat model won't tolerate a third party in the request chain.&lt;/strong&gt; The proxy sees the traffic, even if it only logs metadata. If that's unacceptable, run your own instance of the pattern, or skip it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency in the single-digit milliseconds actually matters.&lt;/strong&gt; The extra hop plus a validation-cache lookup adds overhead. For LLM calls it's invisible against the generation time; for some low-latency, non-LLM APIs it can matter — worth running the numbers yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The model is simple: a secret enters the system exactly once, through the panel, and never leaves the server in plaintext again. The agent gets a tool with a deliberately narrow contract — everything it needs to automate issuing and managing access, and nothing that could leak through the model's context. For agents that deploy their own services and bots, that removes the usual blocker — what to do about a key the agent doesn't have yet — without a human on every step.&lt;/p&gt;

&lt;p&gt;We build ProxyKey at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;Hikmah Labs&lt;/a&gt;, a small studio. You can read more about our work there.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>api</category>
    </item>
    <item>
      <title>Building Voice AI Agents for a Call Centre: Two Case Studies</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:58:20 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/building-voice-ai-agents-for-a-call-centre-two-case-studies-16e1</link>
      <guid>https://dev.to/neostorm112boop/building-voice-ai-agents-for-a-call-centre-two-case-studies-16e1</guid>
      <description>&lt;p&gt;We have two voice agents in our portfolio built for different jobs: one answers inbound calls for a financial services firm, the other calls customers for a logistics company and collects requests. We already wrote about the economics of voice agents elsewhere. This one is about the process itself — how we scoped the task, what decisions we made along the way, what turned out harder than it looked at the start, and what the client ended up seeing on the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different jobs under one label
&lt;/h2&gt;

&lt;p&gt;"Voice agent" is a broad label for things that work quite differently under the hood. The financial firm's task was inbound: the customer calls in, the agent answers routine questions, and hands the complex ones to an operator. The logistics company's task was outbound as much as inbound: the agent both answers calls and calls people from a list to clarify details or collect a request. Both talk in a natural voice and understand ordinary speech, but the scenarios built into them and the data they work with are different. So even though the projects sit under the same theme, they were put together differently from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we started: mapping scenarios and policies
&lt;/h2&gt;

&lt;p&gt;The first step on both projects was not about technology — it was about what actually happens on a call. We work through the call scenarios and the company's policies: what customers ask, what rules operators follow when they answer, where a routine answer ends and an individual case begins. For the support agent, that meant turning the company's policies into a form the agent could actually answer from, without inventing wording of its own. For the calling agent, it meant deciding which fields a conversation absolutely has to fill in for the resulting request to be usable, rather than a string of disconnected phrases.&lt;/p&gt;

&lt;p&gt;What comes out of this step is a line: what the agent takes on, and what stays with people. That line gets fixed in the technical spec along with price and timeline before any work starts, with an NDA where needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What turned out to be hard in practice
&lt;/h2&gt;

&lt;p&gt;The hard part of either project was never getting the agent to talk — that part is solvable. The hard part is the boundary and the handoff.&lt;/p&gt;

&lt;p&gt;For the support agent, it was teaching it to honestly recognise the moment a question falls outside its policies, rather than guessing. An agent that confidently gives a wrong answer to a financial firm's customer is worse than one that admits, in time, that the question is not its to answer and calls in an operator.&lt;/p&gt;

&lt;p&gt;For the calling agent, it was keeping one underlying logic for both inbound and outbound calls, with different scenarios inside each, while still producing data in the same structured shape regardless of who started the conversation. A call the customer starts and a call the agent starts read very differently — the record that comes out of either has to be equally usable.&lt;/p&gt;

&lt;p&gt;And one principle applied to both: the agent does not pass itself off as a human. It introduces itself as an automated assistant. We treated that as a matter of trust in the client's brand rather than a technical constraint — the pretence gets uncovered quickly and damages the impression more than talking to a machine ever would.&lt;/p&gt;

&lt;h2&gt;
  
  
  How context reaches the operator
&lt;/h2&gt;

&lt;p&gt;When the support agent hands a call to a person, the conversation history goes with it — what the customer has already said and what the agent already clarified. The customer does not have to repeat everything to an operator who just picked up. For a call centre where patience runs out faster on the second explanation than on the first, this was a requirement from the start of the build, not an option added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went into the dashboard
&lt;/h2&gt;

&lt;p&gt;The two dashboards were built around different questions a manager actually asks. The support agent's dashboard shows calls, topics and load: how many calls came in, what people ask about most, how load is distributed over time. The calling agent's dashboard shows calls, requests and outcomes: how many conversations happened, how many turned into a request, and how each call ended. In both cases the goal is the same — the manager sees the raw picture of what is happening on the line for the first time, rather than an operator's summary of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What handover looked like
&lt;/h2&gt;

&lt;p&gt;Both projects ran in stages, each ending with a demo of the live agent — something you can actually call and talk to, not a slide deck. Price was fixed in the technical spec before the start and did not change along the way. After handover, we fix issues free for 90 days, then move to retainer support if the client wants it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this approach does not fit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The company has no written policies yet.&lt;/strong&gt; If operators' answers live in their heads rather than in a documented set of rules, the agent has nothing to work from. Turning that knowledge into a form the agent can answer from is a separate task that has to happen before the agent itself gets built.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every call needs legal or medical judgement.&lt;/strong&gt; Where the cost of a wrong answer is high, the agent should honestly flag that risk during discovery, and the project should at most capture a contact for a specialist rather than try to replace the consultation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no one to pick up escalations quickly.&lt;/strong&gt; A dashboard full of topics and load is useless if transferred calls have no one to answer them — the agent removes load from the phone line, but the bottleneck just moves to an already overloaded operator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We build voice AI agents and other AI-driven automation at Hikmah Labs. More about the studio at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;https://hikmah-labs.dev/en/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voice</category>
      <category>automation</category>
      <category>showdev</category>
    </item>
    <item>
      <title>We open-sourced our vault's encryption code — and wrote honestly about what it doesn't guarantee</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Thu, 10 Sep 2026 19:28:32 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/we-open-sourced-our-vaults-encryption-code-and-wrote-honestly-about-what-it-doesnt-guarantee-29hb</link>
      <guid>https://dev.to/neostorm112boop/we-open-sourced-our-vaults-encryption-code-and-wrote-honestly-about-what-it-doesnt-guarantee-29hb</guid>
      <description>&lt;p&gt;We build proxykey, a credential proxy: real API keys sit encrypted on our side, and apps and AI agents talk to us using revocable virtual tokens instead. We recently &lt;a href="https://habr.com/ru/articles/1056070/" rel="noopener noreferrer"&gt;wrote about the scheme itself&lt;/a&gt;. Underneath that post and in DMs, one question kept coming up — the right question to ask any service like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Can you read my keys yourselves?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Short honest answer: technically, yes. Instead of hiding that behind marketing fog, we did two things: published the crypto module in full, and wrote a threat-model page where this point is first and in bold. This post is about why, and exactly what we opened up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "zero-knowledge" is impossible here by design
&lt;/h3&gt;

&lt;p&gt;A proxy has to decrypt the key to put it into the request to the provider — that's the entire job. A password manager can be zero-knowledge: the server never needs your password. A credential proxy can't be: at the moment of the request, the plaintext key exists in the process's memory.&lt;/p&gt;

&lt;p&gt;That leads to an uncomfortable but unavoidable conclusion: a process with full access — and therefore the server operator — can in principle obtain the plaintext. This is a property of the architecture, not a bug. It's the same for every hosted solution in this category — secret managers, proxies, cloud vaults. The only difference is who says so out loud.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the encryption actually protects against
&lt;/h3&gt;

&lt;p&gt;Against a set of concrete, and much more likely, scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Protected?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database dump leak&lt;/td&gt;
&lt;td&gt;Yes — secrets are encrypted, the master key isn't in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup theft&lt;/td&gt;
&lt;td&gt;Yes — same ciphertexts, no key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log leak&lt;/td&gt;
&lt;td&gt;Yes — no keys or auth headers in logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database-only compromise&lt;/td&gt;
&lt;td&gt;Yes — without the KEK from the process environment, ciphertexts are useless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virtual token leak&lt;/td&gt;
&lt;td&gt;Yes — IP binding, rate limits, one-click revocation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Full server compromise / malicious operator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No — honestly, no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full version of this table is on the &lt;a href="https://proxykey.org/security/?utm_source=habr" rel="noopener noreferrer"&gt;security page&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we open-sourced
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/neostorm112-boop/proxykey-crypto" rel="noopener noreferrer"&gt;proxykey-crypto&lt;/a&gt; repo is exactly the envelope-encryption module that encrypts secrets in our production system. 450 lines of TypeScript with tests, no dependencies besides &lt;code&gt;node:crypto&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every secret gets its own DEK; the plaintext is encrypted with &lt;strong&gt;AES-256-GCM&lt;/strong&gt; using AAD = the secret's id (a ciphertext can't be silently swapped into another record);&lt;/li&gt;
&lt;li&gt;the DEK is wrapped with a master key (KEK) that lives only in the process environment — it's never in the database;&lt;/li&gt;
&lt;li&gt;decryption happens in memory for the duration of a single request; the DEK and the plaintext copy are zeroed in &lt;code&gt;finally&lt;/code&gt; on every code path, including exceptions;&lt;/li&gt;
&lt;li&gt;virtual tokens in the database are stored only as SHA-256 hashes;&lt;/li&gt;
&lt;li&gt;18 tests: roundtrip, ciphertext/tag/wrapper tampering, wrong AAD, wrong key version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the open code does NOT guarantee
&lt;/h3&gt;

&lt;p&gt;Also stated plainly, because this is the other half of being honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open code is not proof.&lt;/strong&gt; You can review the design, but a repository can't cryptographically prove that this exact code is what runs on the server. It's a transparency gesture, not an attestation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fill(0)&lt;/code&gt; is best-effort.&lt;/strong&gt; V8 may have already copied buffers internally as part of its own operations, and a decrypted key stored in a JS string is immutable — you can't overwrite it, it lives until garbage collection. We narrowed the window; we didn't close it — and we're saying so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We validate the KEK's length, not its entropy.&lt;/strong&gt; There's no KDF, on purpose: the master key is required to &lt;em&gt;be&lt;/em&gt; a random key (&lt;code&gt;openssl rand -base64 32&lt;/code&gt;), not a password.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repo's README has a "Design notes" section answering the standard reviewer questions — the bounds on the random nonce for GCM, why the DEK wrapper isn't bound to the AAD (to allow future KEK rotation), and so on. If you find something to pick apart beyond that, file an issue — that's the best possible contribution to everyone's security.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to reduce risk even against "the operator"
&lt;/h3&gt;

&lt;p&gt;A practical takeaway for anyone using any hosted key vault, not just ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;create scoped keys, not master keys&lt;/strong&gt;: an OpenAI project key with a $20 budget instead of a key for the whole account — then even a full vault compromise costs you $20, not everything;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;set limits on the provider's side&lt;/strong&gt; — a second line of defense after the vault's own limits;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rotate the originals&lt;/strong&gt; — reissuing at the provider zeroes out the value of anything that may have leaked earlier;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;if you don't trust any of this — that's a rational position&lt;/strong&gt;: the design is reproducible, the crypto module is open, go build your own instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Disclaimer
&lt;/h3&gt;

&lt;p&gt;We're the people behind proxykey (&lt;a href="https://proxykey.org" rel="noopener noreferrer"&gt;proxykey.org&lt;/a&gt;). The service is free. This post isn't "trust us, we're secure" — it's the opposite: here's the boundary up to which it's secure, here's the code, and here's what's left resting on trust. We think that's more honest, and more useful for anyone deciding where to keep their keys.&lt;/p&gt;

&lt;p&gt;I build ProxyKey at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;Hikmah Labs&lt;/a&gt;. You can try the vault at &lt;a href="https://proxykey.org" rel="noopener noreferrer"&gt;proxykey.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>encryption</category>
      <category>opensource</category>
      <category>api</category>
    </item>
    <item>
      <title>Your API Key Will Leak. Make That Not Matter</title>
      <dc:creator>kh.vibecoding</dc:creator>
      <pubDate>Thu, 10 Sep 2026 19:15:38 +0000</pubDate>
      <link>https://dev.to/neostorm112boop/your-api-key-will-leak-make-that-not-matter-1n0g</link>
      <guid>https://dev.to/neostorm112boop/your-api-key-will-leak-make-that-not-matter-1n0g</guid>
      <description>&lt;p&gt;According to GitGuardian's State of Secrets Sprawl report, about 23.7 million secrets — API keys, tokens, passwords — leaked into public GitHub in 2024 alone. The overwhelming majority aren't the result of a hack; they're ordinary commits, logs, and client-side bundles. A fresh key that lands in a public repo gets tried by bots in under a minute.&lt;/p&gt;

&lt;p&gt;The conclusion we reached after the Nth incident: fighting to keep a key from leaking is a war you lose. The winning move is to make the leak useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keys always leak
&lt;/h2&gt;

&lt;p&gt;A single OpenAI key in an average project lives in five places at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in &lt;code&gt;.env&lt;/code&gt; on developers' laptops;&lt;/li&gt;
&lt;li&gt;in CI secrets (and sometimes in CI logs — a &lt;code&gt;printenv&lt;/code&gt; in a debug step);&lt;/li&gt;
&lt;li&gt;in the production config;&lt;/li&gt;
&lt;li&gt;in "just for a minute" scripts (&lt;code&gt;test_gpt.py&lt;/code&gt; with the key hardcoded as a string);&lt;/li&gt;
&lt;li&gt;and, more recently, in the context of an AI agent that "writes the code itself."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every copy is an independent point of failure. The classic countermeasures all work, but each one has a ceiling:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;What it doesn't cover&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; + &lt;code&gt;.gitignore&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;an accidental commit&lt;/td&gt;
&lt;td&gt;logs, CI, agents, "just a quick script"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scanners (gitleaks, trufflehog)&lt;/td&gt;
&lt;td&gt;a leak into git before push&lt;/td&gt;
&lt;td&gt;every other channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret managers (Vault, AWS Secrets Manager)&lt;/td&gt;
&lt;td&gt;storage and delivery&lt;/td&gt;
&lt;td&gt;the key still ends up in the app's runtime environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider-scoped keys&lt;/td&gt;
&lt;td&gt;blast radius&lt;/td&gt;
&lt;td&gt;not every provider supports them; revoking one still breaks every consumer of that key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The common thread: in every scheme, the real key ends up in the consumer's hands at some point.&lt;/p&gt;

&lt;p&gt;Which means it leaks along with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheme: credential proxy
&lt;/h2&gt;

&lt;p&gt;The idea isn't new (it's how, say, payment tokenizers work), but for API keys it's barely used. Break the link between the app and the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app ──(vlt_pass)──▶ proxy ──(real key)──▶ provider
                       │
              status / IP / limits → log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Secret&lt;/strong&gt; — the real key. Entered once, encrypted, and never returned by any API, ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass&lt;/strong&gt; — a virtual token bound to a secret. Its own IP binding, its own rpm/rpd limits, its own lifetime, its own log. This is what applications, scripts, and agents actually get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy&lt;/strong&gt; — validates the pass, decrypts the secret in memory for the duration of a single request, substitutes it, and streams the response back.&lt;/p&gt;

&lt;p&gt;Consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The zone where the original key can leak shrinks down to a single server.&lt;/li&gt;
&lt;li&gt;A leaked pass isn't an incident: wrong IP → 403, over the limit → 429, revocation is one click, the original is never touched.&lt;/li&gt;
&lt;li&gt;Side bonus: you can see who is calling each downstream service and how much, because every consumer has its own pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For your code, the switch is two lines: &lt;code&gt;api.openai.com&lt;/code&gt; → &lt;code&gt;&amp;lt;proxy&amp;gt;/p/openai&lt;/code&gt;, &lt;code&gt;sk-…&lt;/code&gt; → &lt;code&gt;vlt_…&lt;/code&gt;. The path, body, headers, and SSE streaming all pass through unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood (our implementation)
&lt;/h2&gt;

&lt;p&gt;We built this scheme as a service — ProxyKey (Node 22 + Fastify 5 + PostgreSQL + Redis). A few technical decisions that might be interesting independent of the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Envelope encryption&lt;/strong&gt;: every secret gets its own DEK (AES-256-GCM, AAD = secret id), and the DEK is wrapped by a KEK from the environment. Decryption happens in memory for the duration of a single request; the plaintext is never cached or logged anywhere, and the DEK is zeroed out after use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens aren't stored&lt;/strong&gt;: the database only holds SHA-256 hashes of passes; the hot path validates against a Redis cache (TTL 300s) with a fallback to Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degradation is asymmetric&lt;/strong&gt;: if Postgres goes down, we serve from cache (fail-closed for anything not cached); if Redis goes down, we validate against Postgres, but rate limits fail open — validation does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF guard&lt;/strong&gt;: custom base URLs are resolved and checked against private/metadata IP ranges — otherwise a proxy that accepts user-supplied upstreams is a ready-made SSRF vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request logs&lt;/strong&gt;: Postgres partitioned by month, metadata only, no authorization headers or key values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram bots are a special pain&lt;/strong&gt;: the bot token lives in the URL path, and aiogram/grammY (popular Telegram bot libraries) validate its format before the first request. The proxy accepts &lt;code&gt;bot&amp;lt;digits&amp;gt;:vlt_…&lt;/code&gt; and ignores the digits — the format passes client-library validation, and the real token gets substituted server-side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI agents: the main new consumer of keys
&lt;/h2&gt;

&lt;p&gt;This is the actual reason we got into this in the first place. Agents (Claude Code, Cursor, and the like) spin up services and configure bots — they constantly need keys. But anything that ends up in a model's context has to be treated as published: context gets logged, traced, and can be extracted via prompt injection.&lt;/p&gt;

&lt;p&gt;The fix is to give the agent a tool, not a secret: an MCP server for the vault. The agent connects via a URL with an &lt;code&gt;mcp_…&lt;/code&gt; token and can issue, rotate, revoke passes, and read logs. "Read the real key" is simply not in the tool set — that's a property of the access protocol, not a promise we're trusting the agent to keep.&lt;/p&gt;

&lt;p&gt;Our favorite scenario is the "pending secret": an agent deploys a Telegram bot whose token doesn't exist yet. The agent creates a pending pass, wires it into the config, and hands a human a link; the human enters the real token in the panel, the pass activates, and the bot comes alive. The agent finished the job without ever seeing the secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The proxy is a critical dependency.&lt;/strong&gt; If it's down, all your calls are down. Replicas and validation caching help, but you need to understand this going in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proxy sees the traffic.&lt;/strong&gt; The question isn't whether it sees it, it's what it logs. In our implementation, only metadata; body previews are optional and opt-in per pass. If your threat model doesn't allow a third party in the loop, run this pattern yourself — it's reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; One extra hop plus a cache lookup adds single-digit milliseconds against hundreds of milliseconds of LLM generation. For low-latency, non-LLM APIs, do your own math.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;We're the people behind ProxyKey (proxykey.org). The service is free, no card required; the panel, the proxy, and the MCP server are described here honestly, limitations included. The credential-proxy pattern is reproducible without us — if you build your own, just don't skip the SSRF guard and the fail-closed validation path.&lt;/p&gt;




&lt;p&gt;Built at &lt;a href="https://hikmah-labs.dev/en/" rel="noopener noreferrer"&gt;Hikmah Labs&lt;/a&gt;, a small studio I run. ProxyKey itself lives at &lt;a href="https://proxykey.org" rel="noopener noreferrer"&gt;proxykey.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>ai</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
