Storing Third-Party API Keys Safely in a Self-Hosted App
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