<?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: Nemri Ahmed</title>
    <description>The latest articles on DEV Community by Nemri Ahmed (@nemri_ahmed_fb8be006f6205).</description>
    <link>https://dev.to/nemri_ahmed_fb8be006f6205</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%2F4143584%2Ff754c2ca-0bd1-4922-881a-f9c88eb81294.jpg</url>
      <title>DEV Community: Nemri Ahmed</title>
      <link>https://dev.to/nemri_ahmed_fb8be006f6205</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nemri_ahmed_fb8be006f6205"/>
    <language>en</language>
    <item>
      <title>How I store users' exchange API keys (and what I got wrong the first time)</title>
      <dc:creator>Nemri Ahmed</dc:creator>
      <pubDate>Fri, 25 Sep 2026 22:31:50 +0000</pubDate>
      <link>https://dev.to/nemri_ahmed_fb8be006f6205/how-i-store-users-exchange-api-keys-and-what-i-got-wrong-the-first-time-19h7</link>
      <guid>https://dev.to/nemri_ahmed_fb8be006f6205/how-i-store-users-exchange-api-keys-and-what-i-got-wrong-the-first-time-19h7</guid>
      <description>&lt;p&gt;I run a small side project: a BTC grid trading bot where users connect their own Bybit or Binance account with an API key. Their funds stay on their exchange; the bot places spot orders for them.&lt;/p&gt;

&lt;p&gt;That means I store something sensitive: API keys that can place trades on other people's accounts. Here's the setup I landed on, including a mistake I fixed along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The strongest protection isn't encryption
&lt;/h2&gt;

&lt;p&gt;Before any code: the exchange side matters most. Every key must be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trade-only:&lt;/strong&gt; read + spot trade. Withdrawals disabled. If the key can't withdraw, a leaked key can't drain an account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP-restricted:&lt;/strong&gt; locked to my bot server's IP. A leaked key is useless from anywhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Encryption is the second layer. Least privilege is the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Encrypt at rest, with authenticated encryption
&lt;/h2&gt;

&lt;p&gt;My first version used AES-256-CBC via &lt;code&gt;openssl_encrypt&lt;/code&gt;. It hides the data, but CBC alone doesn't detect tampering: if someone modifies the stored ciphertext, decryption can silently return garbage instead of failing.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;authenticated encryption&lt;/strong&gt;. AES-256-GCM encrypts and authenticates in one step, and PHP's OpenSSL extension supports it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encrypt_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$iv&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"aes-256-gcm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_RAW_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tag&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"v2:"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iv&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$cipher&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;decrypt_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"aes-256-gcm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_RAW_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&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="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$plain&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$plain&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// false = tampered or wrong key&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If even one byte of the stored value changes, &lt;code&gt;openssl_decrypt&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; instead of garbage.&lt;/p&gt;

&lt;p&gt;Two details worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A random IV per value&lt;/strong&gt;, stored alongside the ciphertext and the authentication tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A version prefix (&lt;code&gt;v2:&lt;/code&gt;)&lt;/strong&gt;, so old CBC values stay readable during migration. My real &lt;code&gt;decrypt_value&lt;/code&gt; checks the prefix and falls back to the old format, so nobody had to re-enter their keys. New saves use the new format automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Keep the encryption key away from the data
&lt;/h2&gt;

&lt;p&gt;The encrypted keys live in the web app's database. The encryption key lives in a config file &lt;strong&gt;outside&lt;/strong&gt; the database, never committed to git. Stealing the database alone gives an attacker nothing usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The bot server never stores keys
&lt;/h2&gt;

&lt;p&gt;My bots run on a separate VPS, and it doesn't keep users' keys on disk. When a bot starts, it requests that user's key from the web app through an internal endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;over &lt;strong&gt;HTTPS&lt;/strong&gt; only,&lt;/li&gt;
&lt;li&gt;protected by a &lt;strong&gt;shared secret&lt;/strong&gt; header, compared with &lt;code&gt;hash_equals()&lt;/code&gt; to avoid timing attacks,&lt;/li&gt;
&lt;li&gt;and the key is kept &lt;strong&gt;only in memory&lt;/strong&gt; for the life of that bot process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VPS's own API is also firewalled so only the web server's IP can reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What I'd tell anyone building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Be precise in your security claims.&lt;/strong&gt; My landing page originally said "we can never read your keys." That wasn't true: the bot has to decrypt them to trade. I changed it to say exactly what's true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use authenticated encryption from day one.&lt;/strong&gt; GCM costs the same effort as CBC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for the leak.&lt;/strong&gt; Assume a key will leak someday, and make sure it can't withdraw and can't be used from another IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm building this in public at &lt;a href="https://daily-trade.app" rel="noopener noreferrer"&gt;daily-trade.app&lt;/a&gt;. If you've handled user credentials for third-party APIs differently, I'd love to hear how. What would you improve here?&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>security</category>
      <category>python</category>
    </item>
  </channel>
</rss>
