How I store users' exchange API keys (and what I got wrong the first time)
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. That means I store some
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.
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.
1. The strongest protection isn't encryption
Before any code: the exchange side matters most. Every key must be:
- Trade-only: read + spot trade. Withdrawals disabled. If the key can't withdraw, a leaked key can't drain an account.
- IP-restricted: locked to my bot server's IP. A leaked key is useless from anywhere else.
Encryption is the second layer. Least privilege is the first.
2. Encrypt at rest, with authenticated encryption
My first version used AES-256-CBC via openssl_encrypt. 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.
The fix is authenticated encryption. AES-256-GCM encrypts and authenticates in one step, and PHP's OpenSSL extension supports it directly:
function encrypt_value(string $plaintext): string {
$key = hash("sha256", ENCRYPTION_KEY, true);
$iv = random_bytes(12);
$tag = "";
$cipher = openssl_encrypt($plaintext, "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, $tag, "", 16);
return "v2:" . base64_encode($iv . $tag . $cipher);
}
function decrypt_value(string $encoded): ?string {
$key = hash("sha256", ENCRYPTION_KEY, true);
$raw = base64_decode(substr($encoded, 3), true);
if ($raw === false || strlen($raw) < 29) return null;
$plain = openssl_decrypt(substr($raw, 28), "aes-256-gcm", $key, OPENSSL_RAW_DATA,
substr($raw, 0, 12), substr($raw, 12, 16));
return $plain !== false ? $plain : null; // false = tampered or wrong key
}
If even one byte of the stored value changes, openssl_decrypt returns false instead of garbage.
Two details worth copying:
- A random IV per value, stored alongside the ciphertext and the authentication tag.
-
A version prefix (
v2:), so old CBC values stay readable during migration. My realdecrypt_valuechecks the prefix and falls back to the old format, so nobody had to re-enter their keys. New saves use the new format automatically.
3. Keep the encryption key away from the data
The encrypted keys live in the web app's database. The encryption key lives in a config file outside the database, never committed to git. Stealing the database alone gives an attacker nothing usable.
4. The bot server never stores keys
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:
- over HTTPS only,
- protected by a shared secret header, compared with
hash_equals()to avoid timing attacks, - and the key is kept only in memory for the life of that bot process.
The VPS's own API is also firewalled so only the web server's IP can reach it.
5. What I'd tell anyone building something similar
- Be precise in your security claims. 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.
- Use authenticated encryption from day one. GCM costs the same effort as CBC.
- Design for the leak. Assume a key will leak someday, and make sure it can't withdraw and can't be used from another IP.
I'm building this in public at daily-trade.app. If you've handled user credentials for third-party APIs differently, I'd love to hear how. What would you improve here?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.