Skip to content
All writing

NOTES & IDEAS

How I Safely Store Other People's API Keys

My app holds each user's AI key. If my database ever leaked, those keys could be stolen. Here's the simple idea I used to make a leak harmless, explained with a coat-check.

SecurityNode.jsEncryptionBackend

My AI PR Reviewer lets each user bring their own AI key (OpenAI, Gemini, Claude, or Grok), and the app uses that key to review their code. That means the moment someone signs up, I'm holding their private, paid key.

That scared me. If my database ever leaked and those keys were just sitting there in plain text, whoever stole the database could use other people's paid AI accounts. So I built the keys to be useless to a thief, even if they steal the whole database. Here's the idea, in plain English first.

The whole idea: a coat-check

Imagine you run a coat-check at a club. People hand you their expensive coats, and you have to give them back later. But you don't want a burglar to be able to grab them. So:

  1. You lock each coat in a box. (This is encryption: scrambling the data so it can't be read.)
  2. The master key that opens the boxes stays in your pocket, never in the room. (This is the secret password, which I keep on the server, never in the database.)
  3. A burglar who breaks into the room finds only locked boxes. Useless, because the key is in your pocket, not the room. (This is a database leak: the thief gets scrambled junk and nothing to unscramble it with.)

That is the entire point:

Lock up the secret, keep the master key somewhere the thief can't reach, and a break-in gets them nothing.

Why "encryption" and not "hashing"

You may have heard that passwords are "hashed." Hashing is one-way: you scramble it and can never get the original back. That's fine for passwords, because you never need the original, you just check if a new attempt matches.

But I do need the real API key back later, to actually call OpenAI for the user. So hashing won't work. I need encryption, which is two-way: scramble it now, unscramble it later with the key. Encryption is a lockable box; hashing is a paper shredder.

The tool: AES-256-GCM

That's just the name of a well-known, trusted way to do this locking. Two things worth knowing about it:

  • It's strong and standard. You don't invent your own encryption; you use a proven one. This is it.
  • It comes with a built-in tamper seal. When you lock the box, it also creates a little seal. Later, when you unlock it, it checks the seal first. If a thief tried to mess with the locked box, the seal won't match and unlocking fails on purpose instead of handing back broken data. So you get secrecy and a way to detect tampering.

What actually gets saved

When I lock a key, I store a few small pieces in the database. You don't need to memorize the cryptography; just know what each piece is for:

  • the scrambled key - the locked box itself.
  • a salt - a random number used to build a unique lock for this one box, so cracking one box doesn't help crack the others.
  • an IV - another random value that makes two identical coats look different once locked, so a thief can't even tell when two people have the same key.
  • the tamper seal - to detect if anyone messed with the box.

The one thing not saved anywhere in the database is the master password. It lives only on the server. That's the whole trick: the database has the locked boxes and some helper bits, but never the master key to open them.

The code (it's shorter than you'd think)

Node.js ships with all the tools built in, so the whole thing is small. Skim it, the comments explain each line:

import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';

export class EncryptionService {
  // The master password, read from the server's environment.
  // If it's missing, we crash on purpose rather than use a weak default.
  private static getMasterKey(): string {
    const key = process.env.ENCRYPTION_KEY;
    if (!key || key.length < 16) throw new Error('ENCRYPTION_KEY missing or too short.');
    return key;
  }

  // LOCK a value: returns the locked box + helper pieces.
  static encrypt(text: string) {
    const salt = randomBytes(16);   // random, for building this box's lock
    const iv = randomBytes(16);     // random, so identical inputs look different
    const key = scryptSync(this.getMasterKey(), salt, 32); // build the lock

    const cipher = createCipheriv('aes-256-gcm', key, iv);
    let locked = cipher.update(text, 'utf8', 'hex');
    locked += cipher.final('hex');
    const seal = cipher.getAuthTag().toString('hex'); // the tamper seal

    return { encryptedData: locked, iv: iv.toString('hex'),
             authTag: seal, salt: salt.toString('hex') };
  }

  // UNLOCK a value: needs the locked box + the same helper pieces back.
  static decrypt(locked: string, ivHex: string, sealHex: string, saltHex: string) {
    const key = scryptSync(this.getMasterKey(), Buffer.from(saltHex, 'hex'), 32);
    const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
    decipher.setAuthTag(Buffer.from(sealHex, 'hex')); // check the seal
    let text = decipher.update(locked, 'hex', 'utf8');
    text += decipher.final('utf8'); // throws if the seal doesn't match
    return text;
  }
}

encrypt locks a value and hands back the box plus the helper pieces to store. decrypt takes them back, rebuilds the same lock (using the master password and the stored salt), checks the tamper seal, and unlocks. If anything was tampered with, the last line throws instead of returning bad data.

Lock early, unlock in only one place

The rule I follow: lock the key the instant it arrives, and unlock it in only one spot.

When a user saves their key, I encrypt it immediately and store only the locked pieces. I also make sure no part of the app ever sends a key back to the browser, the app can show "you have a key saved," but never the key itself.

The only place the real key is unlocked is deep inside the background job, right before it calls the AI. It's used for that one call and then disappears from memory. It's never logged, never saved, never sent in a response.

So what if the database leaks?

This is the payoff. A thief who steals the entire database gets only locked boxes plus some random helper values. They cannot unlock anything, because the master password was never in the database, it's on the server. A leak goes from "everyone's accounts are stolen" to "the thief got a pile of useless scrambled text."

Takeaway

Holding someone else's secret is a responsibility. The defense is simple to say: lock the secret up, keep the master key somewhere separate, and unlock it only for the brief moment you actually need it. You don't need fancy tools, Node.js has strong encryption built in. The discipline matters more than the math: lock early, store only the locked version, and never keep the real secret lying around.