Guide · Node.js · MongoDB
How to store users' API keys safely
If your app lets people paste their OpenAI, Anthropic or Stripe key, you're holding something that can run up a bill in someone else's name. In October 2026, a breach of a legacy OpenCode system exposed provider keys its users had saved. Here's a setup that limits the damage if your database ever leaks.
The rules
- Encrypt keys at rest with AES-256-GCM, so a database dump on its own is useless.
- Keep the master key out of the database, in your host's secret store or a KMS (AWS KMS, Google Cloud KMS). An attacker then needs both.
- Bind each key to its owner with associated data, so ciphertext can't be moved between accounts.
- Never send a key back to the browser. Show the last 4 characters; to change it, the user pastes a new one.
- Keep keys out of logs: redact request headers in your logger and error tracker.
- Version the master key so you can rotate it, and delete keys you no longer need, including copies in old systems.
1. Encrypt and decrypt
// lib/secretBox.ts: encrypt users' API keys before they touch the database.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
// 32 random bytes, base64, kept in your host's secret store (never in MongoDB).
// Generate one with:
// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
const KEYS: Record<number, Buffer> = {
1: Buffer.from(process.env.SECRETS_KEY_V1!, "base64"),
};
const CURRENT = 1;
export type Sealed = { v: number; iv: string; tag: string; data: string };
// aad binds the ciphertext to its owner: a sealed key copied onto another
// user's record won't decrypt.
export function seal(plaintext: string, aad: string): Sealed {
const iv = randomBytes(12); // never reuse an IV with the same key
const cipher = createCipheriv("aes-256-gcm", KEYS[CURRENT], iv);
cipher.setAAD(Buffer.from(aad));
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
v: CURRENT,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: data.toString("base64"),
};
}
export function open(s: Sealed, aad: string): string {
const decipher = createDecipheriv("aes-256-gcm", KEYS[s.v], Buffer.from(s.iv, "base64"));
decipher.setAAD(Buffer.from(aad));
decipher.setAuthTag(Buffer.from(s.tag, "base64")); // final() throws if tampered with
return Buffer.concat([decipher.update(Buffer.from(s.data, "base64")), decipher.final()]).toString("utf8");
}2. Store only the sealed value
// models/ProviderKey.ts
import { Schema, model } from "mongoose";
const ProviderKey = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, index: true },
provider: { type: String, enum: ["openai", "anthropic", "google"], required: true },
sealed: { type: Object, required: true, select: false }, // only loaded when asked for
last4: String, // what the UI shows, e.g. "…a1B2"
createdAt: { type: Date, default: Date.now },
lastUsedAt: Date,
});
export default model("ProviderKey", ProviderKey);3. Decrypt only on the server, only when calling the API
// Saving a key the user pasted
const aad = `${userId}:${provider}`;
await ProviderKey.create({ userId, provider, sealed: seal(apiKey, aad), last4: apiKey.slice(-4) });
// Using it: decrypt on the server at the moment of the call, and nowhere else
const doc = await ProviderKey.findOne({ userId, provider }).select("+sealed");
const key = open(doc.sealed, `${userId}:${provider}`);
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify(payload),
});Also worth doing
- Rotation: add SECRETS_KEY_V2, set CURRENT = 2, re-seal old records in a background job, then remove V1.
- Logging: redact authorization, x-api-key and any field named like key, token or secret before it reaches your logs.
- Old systems: the OpenCode keys leaked from a legacy system. When you migrate, delete the old copies.
- Ask users to set a spend limit on their provider account. It caps the damage of any leak, yours or theirs.
Think a key already leaked? Paste your code, logs or .env into the secret scanner. It runs in your browser.