All posts

Storing Third-Party API Keys Safely in a Self-Hosted App

August 20, 20267 min read

The situation

Any app that integrates with something else ends up holding other people's credentials — a Sentry token, an AI provider key, a SERP API key. Store them in plaintext and a database backup becomes a credential dump.

AES-256-GCM, dependency-free

Node's crypto module is enough:

export function encrypt(plaintext) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
  const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
  return [b64u(iv), b64u(cipher.getAuthTag()), b64u(ct)].join('.');
}

GCM rather than CBC because it is authenticated: tampering is detected on decrypt rather than silently producing garbage. A fresh 12-byte IV per secret means identical plaintexts produce different ciphertexts.

Decrypt must not throw

export function decrypt(blob) {
  try {
    /* … */
  } catch {
    return null;  // wrong key, tampered, or rotated secret
  }
}

Returning null lets the app treat an undecryptable value as "no credential on file" — which is the safe interpretation. A thrown exception in a background poll takes down the loop instead.

Key derivation, and the rotation trap

Derive the encryption key with scrypt from an explicit ENCRYPTION_KEY, falling back to the app's JWT_SECRET so self-hosters need no extra configuration.

That fallback has a sharp edge worth documenting: rotating `JWT_SECRET` then makes every stored secret undecryptable. Anyone storing integration credentials should set ENCRYPTION_KEY explicitly so the two rotate independently.

Never return the secret

The API returns a masked hint and a status, never the value:

{ connected: true, keyHint: 'sk-…a1b2', source: 'site' }

The hint is enough for a user to recognise which key is stored. It is not enough for anyone else to use it. There is no endpoint that returns the plaintext — not for the owner, not for an admin.

Validate the shape on save

If you know what the credential looks like, check it:

if (!/^[a-f0-9]{40,80}$/i.test(key)) {
  throw new Error('That does not look like a SerpApi key');
}

This catches a pasted placeholder at the point of entry rather than as a mysterious failure three days later in a background job.

What this is not

Encryption at rest protects backups and database access. It does not protect against an attacker with code execution on the app server — that process can decrypt by definition.

For most self-hosted deployments that is the right trade. Be clear about it rather than implying more.

Try InsightsTrack free

Open-source, self-hosted, privacy-first analytics — explore the live demo, no install required.

Open live dashboard