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

  1. Encrypt keys at rest with AES-256-GCM, so a database dump on its own is useless.
  2. 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.
  3. Bind each key to its owner with associated data, so ciphertext can't be moved between accounts.
  4. Never send a key back to the browser. Show the last 4 characters; to change it, the user pastes a new one.
  5. Keep keys out of logs: redact request headers in your logger and error tracker.
  6. 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.