Skip to content

Encrypting Third-Party Credentials at Rest

5 min read · updated August 3, 2026

If your product lets customers bring their own provider keys, you are holding a payment instrument for somebody else. The encryption is the easy part; what makes this hard is that you must be able to decrypt on every request, which bounds what encryption can buy you.

The threat you are actually defending against

Be precise, because the design follows from it. Encrypting a credential at rest defends against: a stolen database backup, a snapshot in an object store with the wrong ACL, a read-only SQL injection, a support engineer with database access, and a dump handed to a subprocessor.

It does not defend against an attacker with code execution on the service that decrypts, because that service holds the key by necessity. Anyone who promises otherwise is describing a system that cannot make the API call. What you are buying is the separation of two compromises: getting the database must not be sufficient.

That framing raises a question worth asking before any of the cryptography: should you be storing the credential at all? A short-lived OAuth token you can refresh, or a delegated credential the provider mints per session, moves the durable secret out of your system entirely and turns a database breach into an inconvenience. Storing a long-lived key is the fallback for providers that offer nothing better, and it should be recorded as an accepted risk rather than assumed as the design. If you do store one, decide up front how you will tell a customer their key was in a breached backup — that sentence is much easier to write when the key was wrapped by a KMS you can prove was not compromised alongside it.

AES-GCM and its two hard rules

AES-256-GCM is the right default: authenticated encryption, so tampering is detected rather than silently decrypting to garbage. Two rules decide whether the implementation is sound.

  • Never reuse a nonce with the same key. This is not a best practice, it is a catastrophic failure — GCM nonce reuse leaks the authentication subkey and allows forgery, and it also reveals the XOR of the two plaintexts. Use 12 random bytes per encryption from a CSPRNG and store the nonce with the ciphertext. At 96 bits, random nonces are safe for the volumes an application like this produces; what is never safe is a fixed nonce or a counter that resets when a process restarts.
  • Verify the tag, and bind the context. The 128-bit tag must be checked on decrypt — every correct library does this and throws. Additionally, pass the record identity as additional authenticated data, so a ciphertext moved from one row to another fails to decrypt. Without AAD, an attacker with write access can swap row A’s credential into row B and your service will happily use it.

Envelope encryption

Encrypting every record directly with one long-lived key means rotating that key requires rewriting every record, and it means one key is used for a very large number of encryptions. Envelope encryption fixes both:

  • A data encryption key (DEK) is generated per record — 32 random bytes — and encrypts the credential.
  • A key encryption key (KEK) encrypts the DEK. The KEK lives in a KMS or an HSM and ideally never enters your process.
  • The row stores the ciphertext, the nonce, the tag, the wrapped DEK and a KEK version. Rotating the KEK means re-wrapping DEKs, which is a small operation; the record ciphertext is untouched.

An implementation

import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

// Stored per record. Versioned from day one -- an unversioned ciphertext
// column is a migration you cannot perform later.
type Sealed = {
  v: 1;
  kekVersion: number;
  wrappedDek: Buffer;  // DEK encrypted by the KMS
  nonce: Buffer;       // 12 bytes, unique per encryption
  tag: Buffer;         // 16 bytes
  ciphertext: Buffer;
};

export async function seal(
  plaintext: string,
  recordId: string,        // becomes AAD: binds ciphertext to its row
  kms: Kms,
): Promise<Sealed> {
  const dek = randomBytes(32);
  const nonce = randomBytes(12);

  const cipher = createCipheriv("aes-256-gcm", dek, nonce);
  cipher.setAAD(Buffer.from(recordId, "utf8"));
  const ciphertext = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]);

  const { wrapped, version } = await kms.wrap(dek);
  dek.fill(0); // best effort; GC may already have copied it

  return { v: 1, kekVersion: version, wrappedDek: wrapped, nonce,
           tag: cipher.getAuthTag(), ciphertext };
}

export async function open(s: Sealed, recordId: string, kms: Kms): Promise<string> {
  const dek = await kms.unwrap(s.wrappedDek, s.kekVersion);
  const decipher = createDecipheriv("aes-256-gcm", dek, s.nonce);
  decipher.setAAD(Buffer.from(recordId, "utf8"));
  decipher.setAuthTag(s.tag);           // throws on tamper or wrong row
  try {
    return Buffer.concat([
      decipher.update(s.ciphertext),
      decipher.final(),
    ]).toString("utf8");
  } finally {
    dek.fill(0);
  }
}

Around that core, the operational rules: decrypt as late as possible and hold the plaintext for as short a time as you can; never log the plaintext, and store a display fingerprint (last four characters plus a hash) so support can identify a key without anyone reading it; and keep the decryption path in as few call sites as possible, so an audit is a grep rather than a survey.

The key-recovery problem

This is the part that is skipped and the part that ends companies. If the KEK is lost, every stored credential is unrecoverable. That is correct cryptography and a total outage for the feature.

The honest options, in the order most teams should consider them. Use a managed KMS and let the provider hold durability — the key never leaves their boundary, and their backup story is better than yours. If you self-manage, split the key with Shamir secret sharing so recovery needs a quorum of custodians rather than one envelope, store the shares in different physical and organisational locations, and rehearse the recovery annually with the actual custodians, because an untested recovery procedure is a document rather than a control. And design for re-entry: the cheapest recovery path is often asking every customer to paste their key again, which is only possible if the system treats a decrypt failure as a re-authentication prompt rather than as a fatal error.

Rotating the key encryption key

With envelope encryption this is routine and should be exercised before it is needed: create the new KEK version, then walk the table unwrapping each DEK with the old version and re-wrapping with the new, writing the new kekVersion per row. Reads keep working throughout because the row says which version it needs. Retire the old KEK only when the count of rows on it reaches zero, and keep that count on a dashboard — a rotation that stalls at 98% is the common outcome and the remaining rows are always the interesting ones.

Rotating the credential itself is a different operation and it is not yours to perform: only the customer can issue a new provider key. What you can build is the path that makes it painless — a clear signal when a stored key stops authenticating, a self-service replacement flow, and a record of when each key was added, so that after a provider-side incident you can tell customers exactly which of their credentials you hold and when they last changed.

Encrypting Third-Party Credentials at Rest · Multigrid