<?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: Manik Lakhanpal</title>
    <description>The latest articles on DEV Community by Manik Lakhanpal (@w16manik).</description>
    <link>https://dev.to/w16manik</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%2F3546812%2F9d86d6f3-deb4-407f-bdcd-36765c7b9418.jpeg</url>
      <title>DEV Community: Manik Lakhanpal</title>
      <link>https://dev.to/w16manik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/w16manik"/>
    <language>en</language>
    <item>
      <title>🔐 Encrypting Broker API Keys at Rest: A Design Walkthrough from AssetLens</title>
      <dc:creator>Manik Lakhanpal</dc:creator>
      <pubDate>Fri, 07 Aug 2026 05:30:37 +0000</pubDate>
      <link>https://dev.to/w16manik/encrypting-broker-api-keys-at-rest-a-design-walkthrough-from-assetlens-25l4</link>
      <guid>https://dev.to/w16manik/encrypting-broker-api-keys-at-rest-a-design-walkthrough-from-assetlens-25l4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Passwords hash; API keys don't. Here's how I store broker credentials in Postgres without pretending the problem is solved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While building &lt;a href="https://github.com/ManikLakhanpal/AssetLens" rel="noopener noreferrer"&gt;AssetLens&lt;/a&gt; — a multi-broker portfolio tracker that pulls data from Binance and Zerodha into one dashboard — I ran into a problem that a lot of "connect your account" apps eventually face:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you store a user's exchange API keys without turning your database into a liability?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post walks through the design behind AssetLens's credential storage layer, why I picked the approach I did, and what I'd change if I were rebuilding it today.&lt;/p&gt;




&lt;h2&gt;
  
  
  😬 The problem
&lt;/h2&gt;

&lt;p&gt;AssetLens needs standing API keys for each user's Binance and Zerodha accounts so it can pull holdings, fund data, and place trades on request. That means the keys have to live somewhere persistent — not just in a session — and they have to be usable by the backend on demand, not just verifiable like a password.&lt;/p&gt;

&lt;p&gt;That second constraint rules out the easy answer.&lt;/p&gt;

&lt;p&gt;With a password, you hash it, compare hashes, and never touch the plaintext again. We use &lt;strong&gt;bcrypt&lt;/strong&gt; for user login passwords in AssetLens — same idea.&lt;/p&gt;

&lt;p&gt;With a broker API key, the server needs the &lt;em&gt;actual value&lt;/em&gt; every time it calls Binance or Zerodha on the user's behalf. So the credential has to be &lt;strong&gt;recoverable&lt;/strong&gt;, not just checkable — which pushes the problem from hashing into &lt;strong&gt;reversible encryption&lt;/strong&gt;, a much easier category to get wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ The approach
&lt;/h2&gt;

&lt;p&gt;I settled on &lt;strong&gt;AES-256-GCM&lt;/strong&gt; for credentials at rest in PostgreSQL, with the encryption key held only in the API service's environment (&lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;), never in the database itself.&lt;/p&gt;

&lt;p&gt;A few reasons GCM specifically, over something simpler like AES-CBC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authenticated encryption.&lt;/strong&gt; GCM produces a tag alongside the ciphertext that lets the app detect tampering before decrypting. If a row gets corrupted or modified out of band, decryption fails loudly instead of silently returning garbage bytes that get passed to a broker API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No separate MAC step.&lt;/strong&gt; With CBC you'd typically bolt on an HMAC to get integrity guarantees, which means more moving parts and more chances to implement it incorrectly. GCM gives you confidentiality and integrity in one primitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-record IVs.&lt;/strong&gt; Each encrypted value gets its own 12-byte initialization vector, generated at write time with &lt;code&gt;crypto.randomBytes()&lt;/code&gt;. Reusing an IV under the same key is one of the more common ways GCM implementations get quietly broken, so this was a deliberate part of the write path rather than an afterthought.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Storage format (not separate columns)
&lt;/h3&gt;

&lt;p&gt;One thing that's easy to get wrong in diagrams: we don't store &lt;code&gt;iv&lt;/code&gt;, &lt;code&gt;authTag&lt;/code&gt;, and &lt;code&gt;ciphertext&lt;/code&gt; as separate database columns. Each secret is a &lt;strong&gt;single hex-encoded blob&lt;/strong&gt; concatenated in a fixed layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┬──────────────┬─────────────────┐
│  IV (12 B)  │ Tag (16 B)   │   Ciphertext    │
└─────────────┴──────────────┴─────────────────┘
                    ↓
            stored as hex TEXT
            (apiKey, apiSecret, accessToken)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Prisma, the schema is intentionally boring — just &lt;code&gt;String&lt;/code&gt; fields on per-user credential tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model BinanceCredentials {
  userId    String @unique
  apiKey    String   // encrypted blob
  apiSecret String   // encrypted blob
}

model ZerodhaCredentials {
  userId      String  @unique
  apiKey      String  // encrypted blob
  apiSecret   String  // encrypted blob
  accessToken String? // encrypted blob (daily Kite token)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The actual crypto helper (~50 lines)
&lt;/h3&gt;

&lt;p&gt;The whole encryption layer lives in one small Node.js module. No wrapper library, just &lt;code&gt;crypto&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createCipheriv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createDecipheriv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;randomBytes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALGORITHM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aes-256-gcm&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;IV_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&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;TAG_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getEncryptionKey&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ENCRYPTION_KEY&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;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENCRYPTION_KEY env var is not set&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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="nx"&gt;key&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="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENCRYPTION_KEY must be 32 bytes (64 hex chars)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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="nf"&gt;getEncryptionKey&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;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IV_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;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCipheriv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALGORITHM&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;iv&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;encrypted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;final&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;authTag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAuthTag&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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="nf"&gt;getEncryptionKey&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;buf&lt;/span&gt; &lt;span class="o"&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;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&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;IV_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;authTag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IV_LENGTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IV_LENGTH&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TAG_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;encrypted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IV_LENGTH&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TAG_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;decipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDecipheriv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALGORITHM&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;iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;decipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAuthTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authTag&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;decipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encrypted&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;decipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;final&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the key once at deploy time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ &lt;strong&gt;Operational gotcha:&lt;/strong&gt; if you rotate &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; without a re-encryption migration, every stored secret becomes undecryptable. There is no magic recovery path — plan for that before you ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write and read paths
&lt;/h3&gt;

&lt;p&gt;Conceptually:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write&lt;/strong&gt; (Settings → &lt;code&gt;PUT /auth/credentials&lt;/code&gt;, JWT required):&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;function&lt;/span&gt; &lt;span class="nf"&gt;storeCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plaintextKey&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AES_256_GCM_encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintextKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ENCRYPTION_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// iv + tag + ciphertext → hex&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;// or apiSecret, accessToken, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read&lt;/strong&gt; (only inside the request that actually calls Binance or Zerodha):&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;function&lt;/span&gt; &lt;span class="nf"&gt;getCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;plaintextKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AES_256_GCM_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ENCRYPTION_KEY&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;plaintextKey&lt;/span&gt;   &lt;span class="c1"&gt;// in-memory for this request only — never sent to the client&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decrypted value never leaves the API process boundary to the &lt;strong&gt;client&lt;/strong&gt;. It's pulled into memory to sign a request to Binance or Zerodha, used, and discarded — it's not logged and not included in API responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zerodha adds a third secret 🔑
&lt;/h3&gt;

&lt;p&gt;Binance is static API key + secret. Zerodha (Kite) has those too, but also a &lt;strong&gt;daily OAuth access token&lt;/strong&gt; that expires and must be refreshed.&lt;/p&gt;

&lt;p&gt;Flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User saves API key + secret via Settings.&lt;/li&gt;
&lt;li&gt;User completes Kite login → &lt;code&gt;POST /zerodha/generate-token&lt;/code&gt; with a &lt;code&gt;request_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Backend exchanges it for an access token, &lt;strong&gt;encrypts it&lt;/strong&gt;, and stores it in &lt;code&gt;ZerodhaCredentials.accessToken&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On portfolio requests, &lt;code&gt;createKiteClient(userId)&lt;/code&gt; decrypts the token and builds a scoped Kite client.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same AES helper, same blob format — just one more field in the credential row.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗄️ Redis: encrypted credentials, plaintext portfolio data
&lt;/h2&gt;

&lt;p&gt;Credentials are also cached in Redis to avoid decrypting from Postgres on every portfolio refresh. Important nuance: &lt;strong&gt;credential caches are re-encrypted&lt;/strong&gt; before they hit Redis.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Redis key&lt;/th&gt;
&lt;th&gt;Encrypted?&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;What's inside&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;binance:credentials:{userId}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{ apiKey, apiSecret }&lt;/code&gt; as encrypted JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zerodha:creds:{userId}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{ apiKey, accessToken }&lt;/code&gt; as encrypted JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;portfolio:summary:{userId}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;Plain JSON totals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;binance:inr:{userId}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;Plain JSON holdings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a Redis snapshot is still sensitive (portfolio numbers are plaintext), but broker keys in cache remain encrypted under the same app key. That's a deliberate performance trade-off, not an accident.&lt;/p&gt;

&lt;p&gt;When Binance credentials are updated, we invalidate the relevant cache keys. Zerodha credential updates should do the same — more on that in the "what I'd change" section.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why not just use a secrets manager?
&lt;/h2&gt;

&lt;p&gt;This is the honest trade-off: a managed secrets service (AWS Secrets Manager, Vault, etc.) would be the "correct" answer for key management at scale, since it handles rotation, access auditing, and takes the encryption key out of application config entirely.&lt;/p&gt;

&lt;p&gt;For a project running as a small self-hosted stack — Postgres, Redis, and a handful of services in Docker Compose — pulling in a secrets manager was more infrastructure than the problem justified at that stage. AES-256-GCM with an env-scoped key gets most of the security benefit (per-user credentials aren't stored in the clear, tampering is detectable) without adding an external dependency the deployment story didn't need yet.&lt;/p&gt;

&lt;p&gt;That said, it's a real limitation worth naming rather than hiding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The encryption key itself is a &lt;strong&gt;single point of failure&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;There's &lt;strong&gt;no built-in key rotation&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If AssetLens grew past a single-operator project, migrating that key into a managed secrets store — with rotation and access logging — would be the first infrastructure change I'd make.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧱 Isolation beyond encryption
&lt;/h2&gt;

&lt;p&gt;Encryption at rest solves one problem, but it doesn't solve cross-user data leakage on its own. AssetLens layers two more things on top:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JWT-scoped requests.&lt;/strong&gt; Every protected route requires a bearer token. The &lt;code&gt;userId&lt;/code&gt; from that token — not anything from the request body — determines whose credentials get decrypted and whose portfolio data gets returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache keys scoped by user ID.&lt;/strong&gt; Portfolio and broker data cached in Redis is keyed per user, so a cache hit for one account can't leak into another account's dashboard under concurrent requests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are exotic techniques individually, but the combination — encrypted storage, request-scoped decryption, and per-user cache isolation — is what makes it reasonable to let a backend service hold live trading credentials for multiple people at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this protects (and what it doesn't) 🎯
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;Mitigated?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres dump / backup leak&lt;/td&gt;
&lt;td&gt;✅ Ciphertext only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tampered DB row&lt;/td&gt;
&lt;td&gt;✅ GCM auth tag fails decrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-user data access via API&lt;/td&gt;
&lt;td&gt;✅ JWT + &lt;code&gt;userId&lt;/code&gt;-scoped queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compromised &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌ Game over for all stored secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compromised API server at runtime&lt;/td&gt;
&lt;td&gt;❌ Plaintext exists in memory during broker calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS stealing JWT from localStorage&lt;/td&gt;
&lt;td&gt;❌ Attacker can act as the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis snapshot&lt;/td&gt;
&lt;td&gt;⚠️ Portfolio data exposed; credential keys still encrypted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Being explicit about the threat model is part of the design doc, not a footnote.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔄 End-to-end flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant User
    participant Web as Web App
    participant API as API Service
    participant PG as PostgreSQL
    participant Redis
    participant Broker as Binance / Zerodha

    User-&amp;gt;&amp;gt;Web: Save API keys in Settings
    Web-&amp;gt;&amp;gt;API: PUT /auth/credentials (JWT)
    API-&amp;gt;&amp;gt;API: encrypt(apiKey), encrypt(apiSecret)
    API-&amp;gt;&amp;gt;PG: upsert credential row (hex blobs)
    API-&amp;gt;&amp;gt;Redis: invalidate stale caches

    User-&amp;gt;&amp;gt;Web: Open portfolio
    Web-&amp;gt;&amp;gt;API: GET /portfolio/summary (JWT)
    API-&amp;gt;&amp;gt;Redis: get binance:credentials:{userId}
    alt cache miss
        API-&amp;gt;&amp;gt;PG: fetch credential row
        API-&amp;gt;&amp;gt;API: decrypt blobs
        API-&amp;gt;&amp;gt;Redis: set encrypted credential cache
    end
    API-&amp;gt;&amp;gt;Broker: signed request with plaintext key (in-memory)
    Broker--&amp;gt;&amp;gt;API: holdings / balances
    API--&amp;gt;&amp;gt;Web: portfolio JSON (no secrets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔧 What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Looking back, a few things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key rotation was never built in.&lt;/strong&gt; The encryption key is static for the life of the deployment. A rotation scheme (even a simple "re-encrypt on next login" strategy) would close that gap without requiring a full secrets-manager migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No audit trail on decryption.&lt;/strong&gt; Right now there's no record of &lt;em&gt;when&lt;/em&gt; a given credential was decrypted and used. For anything handling brokerage-level trading access, a lightweight audit log of decrypt events would be a cheap addition with real value if something ever needed investigating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zerodha cache invalidation is incomplete.&lt;/strong&gt; Updating Binance credentials clears &lt;code&gt;binance:credentials:{userId}&lt;/code&gt; from Redis. Updating Zerodha credentials doesn't yet clear &lt;code&gt;zerodha:creds:{userId}&lt;/code&gt; — stale session data can linger for up to 24 hours. I'd add symmetric invalidation on credential save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portfolio data in Redis is plaintext.&lt;/strong&gt; Fine for a personal project; for a stricter threat model I'd either encrypt those caches too or shorten TTLs aggressively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Security work like this rarely feels finished — it's a series of trade-offs made against the actual constraints of the project at the time, with the honest ones documented so the next person (often future-me) knows exactly where the shortcuts are.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ If you're building something similar
&lt;/h2&gt;

&lt;p&gt;Quick checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Use &lt;strong&gt;AES-256-GCM&lt;/strong&gt; (or another AEAD) — not bare AES-CBC without a MAC&lt;/li&gt;
&lt;li&gt;[ ] Generate a proper 256-bit key: &lt;code&gt;openssl rand -hex 32&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fresh random IV per encryption — never reuse under the same key&lt;/li&gt;
&lt;li&gt;[ ] Store IV + tag + ciphertext in a format &lt;em&gt;you&lt;/em&gt; own (separate columns optional)&lt;/li&gt;
&lt;li&gt;[ ] Scope every decrypt to &lt;code&gt;req.userId&lt;/code&gt; from a verified JWT — never trust the request body for identity&lt;/li&gt;
&lt;li&gt;[ ] Never return decrypted secrets to the client&lt;/li&gt;
&lt;li&gt;[ ] Document what happens when the encryption key rotates (or doesn't)&lt;/li&gt;
&lt;li&gt;[ ] Be honest about Redis, logs, and in-memory exposure&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📎 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ManikLakhanpal/AssetLens" rel="noopener noreferrer"&gt;github.com/ManikLakhanpal/AssetLens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto module:&lt;/strong&gt; &lt;code&gt;apps/api/src/services/auth/cryptoService.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential save path:&lt;/strong&gt; &lt;code&gt;apps/api/src/services/auth/authService.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Security is a process, not a checkbox. This is where AssetLens stands today — not where I'd stop if it were handling other people's money at scale.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>encryption</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
