One key encrypts and decrypts. You reach for this rarely — TLS already protects data in transit — but you need it for fields that must stay unreadable to anyone holding a database dump: a stored API credential, an OAuth refresh token. Use an authenticated cipher, which emits a short tag alongside the ciphertext and refuses to decrypt if either was altered. AES-256-GCM is the default; chacha20-poly1305 is a drop-in replacement for CPUs without AES instructions. Unauthenticated modes such as aes-256-cbc decrypt tampered input into plausible garbage, the root of a long line of padding-oracle vulnerabilities.
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const key = randomBytes(32); // 256-bit key: load from a KMS, never hard-code
function seal(plaintext, aad) {
const iv = randomBytes(12); // 96-bit nonce: fresh for every single message
const c = createCipheriv('aes-256-gcm', key, iv);
c.setAAD(Buffer.from(aad)); // authenticated but not encrypted
const ct = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]).toString('base64url');
}
function open(token, aad) {
const raw = Buffer.from(token, 'base64url');
const d = createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
d.setAAD(Buffer.from(aad));
d.setAuthTag(raw.subarray(12, 28)); // must be set before final()
return Buffer.concat([d.update(raw.subarray(28)), d.final()]).toString('utf8');
}
const token = seal('4111 1111 1111 1111', 'user:4711');
console.log('stored:', token, '\nopened:', open(token, 'user:4711'));
const flip = Buffer.from(token, 'base64url'); flip[30] ^= 1;
for (const [why, t, aad] of [['wrong aad ', token, 'user:9999'],
['flipped bit', flip.toString('base64url'), 'user:4711']]) {
try { open(t, aad); } catch (e) { console.log(why + ':', e.message); }
}stored: -YEaXHMMDAlBMuefa1LT9-jR_A2bbJ9Pq6jAinct5-WfMcBw680pcpRzFeY4Epw opened: 4111 1111 1111 1111 wrong aad : Unsupported state or unable to authenticate data flipped bit: Unsupported state or unable to authenticate data
Three rules follow. The IV must be unique per message under a key: GCM repeats its keystream if a nonce repeats, and two messages sharing a key and nonce leak their XOR and the authentication key itself. Store it alongside — an IV is not secret, and that fixed 28-byte overhead is why 19 bytes of plaintext became 47. The tag is not optional: omit setAuthTag and final() throws. AAD binds context, so a ciphertext stolen from one row cannot be pasted into another user's row. Keep the key in a service such as AWS 24 KMS, and prefix ciphertext with a key id like v2: so rotation does not orphan old rows.