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 →
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
| Threat | Protected? | Why |
|---|---|---|
| Database dump, stolen backup or exposed replica | Yes | Only ciphertext is stored; the master key is elsewhere |
| Someone with read access browsing the collection | Yes | They see encrypted blobs and a short preview |
| A key showing up in logs or error trackers | Mostly | Redaction catches known formats; unknown formats can slip through |
| A row copied onto another user's record | Yes | Decryption is bound to tenant and record ID and fails |
| An attacker running code inside your app with the master key loaded | No | They 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.
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.
# Generate a 32-byte master key once, store it in your secret manager
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"// 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)
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));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.
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 likesk-...bfe9, which is all the UI needs.list()uses a projection that removessecret, 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:
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 revokedKeep 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:
// 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
- 1Rotating 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. - 2A 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.
- 3After 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
| Check | Where |
|---|---|
| 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 row | Secret manager or KMS |
| Ciphertext bound to tenant and record (AAD) | key-vault.ts |
| Secret field excluded from every read except the call | provider-keys.ts |
| Plaintext never returned by the API, never logged | provider-keys.ts, redact.ts |
| Revocation deletes the ciphertext | provider-keys.ts |
| Last-used time recorded; unusual usage noticed | withKey() |
| Master key rotation tested before you need it | rewrap() |
Common questions
Should I hash my users' API keys like passwords?+
Is an environment variable good enough for the master key?+
Can MongoDB encrypt the field for me?+
How do I stop a leaked key from running up a bill?+
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