Guide

How to store your users' API keys safely in Node.js and MongoDB

By Aman Singh · Published · 11 min read

Many apps ask users to paste their own OpenAI, Anthropic or Stripe key. Once you save that key, a leak of your database is a leak of your users' accounts, and their bills. In October 2026 OpenCode told users that third-party provider keys saved in a legacy system it was retiring may have been exposed, and asked them to revoke and replace them. The Hacker News discussion quickly turned to the question every "bring your own key" app has to answer: how were those keys stored?

This guide shows one solid answer for a Node.js and MongoDB backend: envelope encryption with Node's built-in crypto module, a collection design that never hands the key back by accident, log redaction, and key rotation. All of it was run in October 2026 on Node.js 20 against MongoDB 7.0 with version 6.21 of the official mongodb driver, including the cases that should fail.

Did your hosting platform leak your secrets this month? Rotate them in the right order →

A stolen database dump holds two encrypted blobs per key and no way to open them, because the master key is somewhere else.

First: do you need to store them?

The safest key is the one you never hold. Before building any of this, check whether you can avoid it:

  • Use the provider's OAuth or connect flow where one exists. Stripe Connect, for example, lets you act for an account without the user pasting a secret key.
  • Use the key only for the request. If the user's key is needed for one action, take it, use it and drop it.
  • Ask for the narrowest key. Restricted or project-scoped keys with spend limits make a leak cheaper.

If your product runs jobs in the background with the user's key, you do need to store it. Then do it like this.

What this protects against

ThreatProtected?Why
Database dump, stolen backup or exposed replicaYesOnly ciphertext is stored; the master key is elsewhere
Someone with read access browsing the collectionYesThey see encrypted blobs and a short preview
A key showing up in logs or error trackersMostlyRedaction catches known formats; unknown formats can slip through
A row copied onto another user's recordYesDecryption is bound to tenant and record ID and fails
An attacker running code inside your app with the master key loadedNoThey can decrypt like your app does; limit blast radius instead

Be honest about the last row. Encryption at rest protects data at rest. Against an attacker inside the running app, what helps is spend limits, short-lived access to the master key (a key management service) and noticing unusual usage quickly.

Envelope encryption with Node's crypto

Each saved key gets its own random 32-byte data key. The data key encrypts the API key with AES-256-GCM, which also produces an authentication tag, so any change to the stored bytes is detected. Then the master key encrypts ("wraps") the data key. The database stores both encrypted values plus the ID of the master key used.

key-vault.ts
import {
  createCipheriv,
  createDecipheriv,
  createHmac,
  randomBytes,
} from "node:crypto";

// Envelope encryption for API keys your users give you.
// Each stored key gets its own random data key. The data key encrypts the
// secret; a master key (kept outside the database) encrypts the data key.

export type MasterKey = { id: string; key: Buffer }; // 32 bytes for AES-256

export type StoredSecret = {
  kekId: string; // which master key wrapped the data key
  wrappedKey: string; // data key, encrypted with the master key
  wrapIv: string;
  wrapTag: string;
  ciphertext: string; // the API key, encrypted with the data key
  iv: string;
  tag: string;
};

const b64 = (buf: Buffer) => buf.toString("base64");
const unb64 = (s: string) => Buffer.from(s, "base64");

function seal(key: Buffer, plaintext: Buffer, aad: Buffer) {
  const iv = randomBytes(12); // never reuse an IV with the same key
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  cipher.setAAD(aad);
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  return { iv, ciphertext, tag: cipher.getAuthTag() };
}

function open(key: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer, aad: Buffer) {
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAAD(aad);
  decipher.setAuthTag(tag); // a changed byte anywhere makes final() throw
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

// The context ties a ciphertext to one tenant and one record, so a row copied
// onto another tenant's record fails to decrypt instead of leaking.
const contextOf = (tenantId: string, recordId: string) =>
  Buffer.from(`${tenantId}:${recordId}`, "utf8");

export function encryptSecret(
  secret: string,
  master: MasterKey,
  tenantId: string,
  recordId: string,
): StoredSecret {
  const aad = contextOf(tenantId, recordId);
  const dataKey = randomBytes(32);
  const body = seal(dataKey, Buffer.from(secret, "utf8"), aad);
  const wrap = seal(master.key, dataKey, aad);
  dataKey.fill(0);
  return {
    kekId: master.id,
    wrappedKey: b64(wrap.ciphertext),
    wrapIv: b64(wrap.iv),
    wrapTag: b64(wrap.tag),
    ciphertext: b64(body.ciphertext),
    iv: b64(body.iv),
    tag: b64(body.tag),
  };
}

export function decryptSecret(
  stored: StoredSecret,
  masters: Map<string, Buffer>,
  tenantId: string,
  recordId: string,
): string {
  const master = masters.get(stored.kekId);
  if (!master) throw new Error(`Unknown master key ${stored.kekId}`);
  const aad = contextOf(tenantId, recordId);
  const dataKey = open(master, unb64(stored.wrapIv), unb64(stored.wrappedKey), unb64(stored.wrapTag), aad);
  try {
    return open(dataKey, unb64(stored.iv), unb64(stored.ciphertext), unb64(stored.tag), aad).toString("utf8");
  } finally {
    dataKey.fill(0);
  }
}

// Rotating the master key only re-wraps the small data key.
// The encrypted API key itself is not touched.
export function rewrap(
  stored: StoredSecret,
  masters: Map<string, Buffer>,
  next: MasterKey,
  tenantId: string,
  recordId: string,
): StoredSecret {
  const aad = contextOf(tenantId, recordId);
  const oldMaster = masters.get(stored.kekId);
  if (!oldMaster) throw new Error(`Unknown master key ${stored.kekId}`);
  const dataKey = open(oldMaster, unb64(stored.wrapIv), unb64(stored.wrappedKey), unb64(stored.wrapTag), aad);
  const wrap = seal(next.key, dataKey, aad);
  dataKey.fill(0);
  return { ...stored, kekId: next.id, wrappedKey: b64(wrap.ciphertext), wrapIv: b64(wrap.iv), wrapTag: b64(wrap.tag) };
}

// What you can safely show and search on. The fingerprint is a keyed hash, so
// you can detect "this key is already saved" without storing the key.
export function describeKey(secret: string, fingerprintSecret: Buffer) {
  return {
    fingerprint: createHmac("sha256", fingerprintSecret).update(secret).digest("hex").slice(0, 16),
    preview: `${secret.slice(0, 3)}...${secret.slice(-4)}`,
  };
}
  • A new 12-byte IV for every encryption. Reusing an IV with the same key breaks GCM's guarantees. randomBytes(12) per call avoids it.
  • Additional authenticated data (AAD). The tenant and record IDs are mixed into the authentication, though not stored in the ciphertext. A row moved to another tenant fails to decrypt instead of leaking.
  • A master key ID on every row. It lets you rotate the master key without a big-bang migration.
  • A fingerprint, not a hash of the raw key. An HMAC with a separate secret lets you spot duplicates without storing anything an attacker could test guesses against.

The master key is 32 random bytes. Generate it once and keep it in a secret manager or, better, a key management service that does the wrapping for you so the key never leaves it. An environment variable is the minimum.

Terminal
# Generate a 32-byte master key once, store it in your secret manager
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Loading master keys
// At startup: load every master key you still need to decrypt with,
// and mark the newest one as the key for new writes.
const masters = new Map([
  ["kek-2026-10", Buffer.from(process.env.KEY_MASTER_2026_10!, "base64")],
]);
const current = { id: "kek-2026-10", key: masters.get("kek-2026-10")! };

What the tests showed

The test encrypts a fake key, then tries the things that must fail: a changed byte, a row copied to another tenant, the wrong master key, and an old row after its master key is retired.

Show the test script (test.ts)
test.ts
import { randomBytes } from "node:crypto";
import { decryptSecret, describeKey, encryptSecret, rewrap } from "./key-vault.ts";
import { redact } from "./redact.ts";

const v1 = { id: "kek-2026-10", key: randomBytes(32) };
const masters = new Map([[v1.id, v1.key]]);
const fingerprintSecret = randomBytes(32);

const userKey = "sk-test-" + randomBytes(24).toString("hex");
const stored = encryptSecret(userKey, v1, "tenant_a", "key_1");

const check = (label: string, fn: () => unknown) => {
  try {
    console.log(`ok    ${label}: ${String(fn())}`);
  } catch (err) {
    console.log(`fails ${label}: ${(err as Error).message}`);
  }
};

check("round trip", () => decryptSecret(stored, masters, "tenant_a", "key_1") === userKey);
check("stored document contains the key?", () => JSON.stringify(stored).includes(userKey));
check("same key encrypted twice gives the same ciphertext?", () =>
  encryptSecret(userKey, v1, "tenant_a", "key_1").ciphertext === stored.ciphertext);

// Tampering and copying rows between tenants must fail, not decrypt.
const flipped = Buffer.from(stored.ciphertext, "base64");
flipped[0] ^= 1;
check("one flipped byte", () => decryptSecret({ ...stored, ciphertext: flipped.toString("base64") }, masters, "tenant_a", "key_1"));
check("row copied to tenant_b", () => decryptSecret(stored, masters, "tenant_b", "key_1"));
check("wrong master key", () => decryptSecret(stored, new Map([[v1.id, randomBytes(32)]]), "tenant_a", "key_1"));

// Rotate the master key: re-wrap, then retire the old one.
const v2 = { id: "kek-2027-01", key: randomBytes(32) };
masters.set(v2.id, v2.key);
const rotated = rewrap(stored, masters, v2, "tenant_a", "key_1");
masters.delete(v1.id);
check("after rotation, ciphertext unchanged", () => rotated.ciphertext === stored.ciphertext);
check("after rotation, decrypts with new master only", () => decryptSecret(rotated, masters, "tenant_a", "key_1") === userKey);
check("old row after old master retired", () => decryptSecret(stored, masters, "tenant_a", "key_1"));

const a = describeKey(userKey, fingerprintSecret);
check("fingerprint stable", () => describeKey(userKey, fingerprintSecret).fingerprint === a.fingerprint);
check("preview", () => a.preview);

const logLine = `call failed for key ${userKey} with header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U`;
check("redacted log line", () => redact(logLine));
Output
ok    round trip: true
ok    stored document contains the key?: false
ok    same key encrypted twice gives the same ciphertext?: false
fails one flipped byte: Unsupported state or unable to authenticate data
fails row copied to tenant_b: Unsupported state or unable to authenticate data
fails wrong master key: Unsupported state or unable to authenticate data
ok    after rotation, ciphertext unchanged: true
ok    after rotation, decrypts with new master only: true
fails old row after old master retired: Unknown master key kek-2026-10
ok    fingerprint stable: true
ok    preview: sk-...f80b
ok    redacted log line: call failed for key sk-[REDACTED] with header Authorization: Bearer [JWT REDACTED]

The "fails" lines are the point: tampering, swapping and wrong keys raise an error from decipher.final() instead of returning garbage or someone else's key. Also note line 3: the same key encrypted twice gives different ciphertext, so equal keys cannot be spotted by comparing rows.

Storing it in MongoDB

The collection keeps the encrypted blob in one field, secret, and everything you need to show in the UI next to it. One rule shapes the code: only the method that makes the provider call ever reads secret.

provider-keys.ts
import { randomUUID } from "node:crypto";
import type { Collection, Db } from "mongodb";
import {
  decryptSecret,
  describeKey,
  encryptSecret,
  type MasterKey,
  type StoredSecret,
} from "./key-vault.ts";

type ProviderKeyDoc = {
  _id: string;
  tenantId: string;
  provider: "openai" | "anthropic" | "google";
  secret: StoredSecret;
  fingerprint: string;
  preview: string;
  createdAt: Date;
  lastUsedAt: Date | null;
  revokedAt: Date | null;
};

// Every read except the one that makes the provider call leaves the secret out.
const PUBLIC_FIELDS = { secret: 0 } as const;

export class ProviderKeys {
  private col: Collection<ProviderKeyDoc>;

  constructor(
    db: Db,
    private master: MasterKey,
    private masters: Map<string, Buffer>,
    private fingerprintSecret: Buffer,
  ) {
    this.col = db.collection<ProviderKeyDoc>("provider_keys");
  }

  async setup() {
    // One active copy of the same key per tenant.
    await this.col.createIndex(
      { tenantId: 1, fingerprint: 1 },
      { unique: true, partialFilterExpression: { revokedAt: null } },
    );
  }

  async add(tenantId: string, provider: ProviderKeyDoc["provider"], apiKey: string) {
    const _id = randomUUID();
    const { fingerprint, preview } = describeKey(apiKey, this.fingerprintSecret);
    await this.col.insertOne({
      _id,
      tenantId,
      provider,
      secret: encryptSecret(apiKey, this.master, tenantId, _id),
      fingerprint,
      preview,
      createdAt: new Date(),
      lastUsedAt: null,
      revokedAt: null,
    });
    return { id: _id, provider, preview }; // never echo the key back
  }

  list(tenantId: string) {
    return this.col
      .find({ tenantId, revokedAt: null }, { projection: PUBLIC_FIELDS })
      .toArray();
  }

  // The only place the plaintext exists: inside the call, then it is dropped.
  async withKey<T>(tenantId: string, id: string, call: (apiKey: string) => Promise<T>) {
    const doc = await this.col.findOneAndUpdate(
      { _id: id, tenantId, revokedAt: null },
      { $set: { lastUsedAt: new Date() } },
    );
    if (!doc) throw new Error("Key not found or revoked");
    return call(decryptSecret(doc.secret, this.masters, tenantId, id));
  }

  async revoke(tenantId: string, id: string) {
    // Remove the ciphertext too, so a revoked key cannot be recovered later.
    await this.col.updateOne(
      { _id: id, tenantId },
      { $set: { revokedAt: new Date() }, $unset: { secret: "" } },
    );
  }
}
  • add() never echoes the key. It returns the ID and a preview like sk-...bfe9, which is all the UI needs.
  • list() uses a projection that removes secret, so an API route that returns the list cannot leak it.
  • withKey() decrypts inside a callback and records when the key was last used, which helps you spot unusual activity.
  • revoke() deletes the ciphertext instead of only flagging the row.

Run against a temporary MongoDB 7.0 server, it printed:

Output (IDs shortened)
add() returns      { id: 'edee9f1e-...', provider: 'openai', preview: 'sk-...bfe9' }
plaintext in DB?   false
list() fields      _id, tenantId, provider, fingerprint, preview, createdAt, lastUsedAt, revokedAt
withKey() got key  true
tenant_b access    Key not found or revoked
same key twice     E11000 duplicate key error ... index: tenantId_1_fingerprint_1
after revoke       { secretKept: false, revoked: true }
use revoked key    Key not found or revoked

Keep keys out of logs and errors

Encrypted storage is pointless if the key is printed to your logs on the first failed call. Provider SDK errors and HTTP client debug output can include request headers. Run anything you log or send to an error tracker through a redaction step:

redact.ts
// Last line of defence: scrub anything that looks like a secret before it is
// logged or returned in an error. Patterns are examples; add your providers'.
const PATTERNS: [RegExp, string][] = [
  [/sk-[A-Za-z0-9_-]{16,}/g, "sk-[REDACTED]"], // OpenAI / Anthropic style
  [/AIza[0-9A-Za-z_-]{30,}/g, "AIza[REDACTED]"], // Google API keys
  [/(?:ghp|gho|ghs|github_pat)_[A-Za-z0-9_]{20,}/g, "gh_[REDACTED]"], // GitHub tokens
  [/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[JWT REDACTED]"],
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[PRIVATE KEY REDACTED]"],
];

export function redact(text: string): string {
  return PATTERNS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text);
}

The last line of the test output shows it removing both the raw key and a bearer token. Patterns only catch formats you list, so add the key formats of every provider you support. Also never put keys in URLs or query strings, where proxies and analytics record them.

Rotation and revocation

  1. 1
    Rotating your master key. Add the new key, use it for new writes, then run rewrap() over existing rows. Only the small data key is re-encrypted; the API key ciphertext does not change. Retire the old master key once no row references it. The test shows an old row failing cleanly after that.
  2. 2
    A user replacing their key. Add the new key as a new record, switch to it, then revoke the old one. Never update a secret in place.
  3. 3
    After a suspected leak. Revoking in your database is not enough: the key still works at the provider. Tell users to revoke and replace it in the provider's dashboard, and check their recent usage, which is what OpenCode asked its users to do.

Checklist

CheckWhere
Each key encrypted with its own data key (AES-256-GCM, fresh IV)key-vault.ts
Master key outside the database, with an ID on every rowSecret manager or KMS
Ciphertext bound to tenant and record (AAD)key-vault.ts
Secret field excluded from every read except the callprovider-keys.ts
Plaintext never returned by the API, never loggedprovider-keys.ts, redact.ts
Revocation deletes the ciphertextprovider-keys.ts
Last-used time recorded; unusual usage noticedwithKey()
Master key rotation tested before you need itrewrap()

Common questions

Should I hash my users' API keys like passwords?+
No. You need the original key to call the provider, so it has to be encrypted, not hashed. Hashing is right for keys you issue yourself, where you only need to check a key someone presents.
Is an environment variable good enough for the master key?+
It is the minimum. A key management service is better, because the master key never leaves it and every unwrap can be logged and limited.
Can MongoDB encrypt the field for me?+
MongoDB offers client-side field level encryption and Queryable Encryption, which follow the same envelope idea with their own key management. They are worth a look if you want the driver to handle it. This guide uses plain Node crypto so the moving parts are visible.
How do I stop a leaked key from running up a bill?+
Ask users for keys with provider-side spend limits where available, track usage per key in your app, and alert or pause when it jumps.

Want a second pair of eyes on your key handling?

If your app stores keys or tokens for its users and you want it built or reviewed properly, see Node.js API development or AI and LLM integrations.

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides