AES-256-GCM in Node.js: Encrypting PII the Right Way
AES-256-GCM in Node.js: Encrypting PII the Right Way
Most AES-GCM code on the internet is wrong.
Not "suboptimal" wrong. Not "could be better" wrong. Wrong in ways that silently destroy your encryption guarantees and turn your PII protection into theater. I've seen production codebases using 16-byte IVs instead of 12, hex-encoding keys and halving their effective length, and importing crypto-js -- a package that's been deprecated since October 2023 and was literally used as a supply chain attack vector in March 2026.
The algorithm isn't the problem. AES-256-GCM is solid. NIST confirms it holds up even against quantum attacks (Grover's algorithm halves the key strength to a 128-bit equivalent, which is still far beyond brute-force). The problem is the 10 lines of code between you and a correct implementation.
Let's fix that.
Why GCM and Not CBC
AES has multiple modes of operation. You've probably seen AES-CBC in older tutorials. The difference matters.
CBC gives you confidentiality. Someone can't read your data. But it doesn't tell you if someone modified your data. You need a separate HMAC for that, and getting the order right (encrypt-then-MAC, not MAC-then-encrypt) is another place to screw up.
GCM gives you both. Authenticated encryption. If someone tampers with the ciphertext, the auth tag check fails and decryption throws. One operation, two guarantees.
There's no good reason to use CBC for new code in 2026.
The Implementation
Here's a complete encrypt/decrypt module using node:crypto. Zero dependencies. No npm packages to get hijacked.
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // GCM spec: 96 bits
const TAG_LENGTH = 16; // 128-bit auth tag
const KEY_LENGTH = 32; // 256 bits
export function encrypt(plaintext, key) {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Pack as: IV (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted]);
}
export function decrypt(packed, key) {
const iv = packed.subarray(0, IV_LENGTH);
const tag = packed.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = packed.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: TAG_LENGTH,
});
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString('utf8');
}
A few things to notice.
The IV is 12 bytes. Not 16. GCM is specified for 96-bit nonces. If you pass 16 bytes, Node.js won't complain -- it'll silently run a GHASH reduction that weakens your construction. This is the single most common mistake in every Stack Overflow answer I've audited.
The auth tag is extracted after final(). Call getAuthTag() before that and you get garbage. The tag is the proof that your ciphertext hasn't been tampered with. Lose it, and GCM degrades to CTR mode with no integrity protection.
IV + tag + ciphertext are packed into a single buffer. This is a convention, not a requirement. But it means you can store or transmit the result as one blob and unpack it later. No separate columns, no metadata to lose.
Key Management
The hardest part of encryption isn't the algorithm. It's the key.
// Generate a key (do this ONCE, store it securely)
import { randomBytes } from 'node:crypto';
const key = randomBytes(32);
console.log(key.toString('base64'));
// → e.g. "k3J8mQ2p..."
# Or from the command line
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Store this in your secrets manager. AWS Secrets Manager, HashiCorp Vault, OpenBao, Doppler -- whatever your team uses. Not in .env. Not in your repo. Not hardcoded in a config file.
If you're loading from an environment variable, decode it at startup:
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'base64');
if (key.length !== 32) {
throw new Error(`Invalid key length: ${key.length} bytes, expected 32`);
}
That length check matters. I've seen codebases where someone stored the key as a hex string, read it as UTF-8, and ended up with a 64-byte buffer that Node.js silently accepted by truncating. Half the key, gone.
The Nonce Problem
GCM's 12-byte IV gives you a 96-bit nonce space. If you generate nonces randomly (which the code above does), you hit a 50% collision probability at roughly 2^48 messages -- about 281 trillion. That sounds like a lot.
It isn't, for every use case. The Kopia backup tool filed a bug in February 2026 because their high-volume chunk encryption was approaching collision territory with random nonces and a single long-lived key.
But for PII encryption in a typical web app? You're fine. If you're encrypting user emails, addresses, and SSNs, you're not hitting 281 trillion records per key. Rotate your keys annually and you'll never come close.
If you are in a high-volume scenario -- millions of writes per second, distributed writers, long-lived keys -- look at counter-based nonces or AES-GCM-SIV. GCM-SIV (RFC 8452) derives the internal IV from the plaintext, so a repeated nonce only leaks whether two plaintexts were identical. No keystream recovery, no auth key compromise. Available in Node.js via micro-aes-gcm if you need it.
What Samsung Got Wrong
In 2022, researchers broke Samsung's TrustZone hardware keystore -- the thing that's supposed to be the most secure part of your Galaxy phone. The attack? The TEE let callers supply their own IV for AES-GCM operations.
Request the same IV twice with different plaintexts, and you recover the keystream. Do it a few more times, and you recover the GHASH key. Then the encryption key itself. Millions of devices, compromised by a design choice that violated the most basic rule of GCM: never let the caller control the nonce.
The lesson: randomBytes(12) isn't just a best practice. It's the entire security model.
Don't Use crypto-js
I need to say this explicitly because crypto-js still gets massive weekly downloads.
The package has been deprecated since v4.2.0. The maintainer stopped updating it. In March 2026, attackers published a lookalike package called plain-crypto-js and injected it into Axios versions 1.14.1 and 0.30.4. If your lockfile wasn't pinned, you pulled in credential-stealing malware from a package that existed because crypto-js created a gap in the ecosystem.
node:crypto is built into Node.js. It's maintained by the Node.js security team. It has zero dependencies. Zero supply chain surface. There is no reason to use a third-party crypto library for AES-GCM in 2026.
If you want to check what's lurking in your dependency tree, Vekt scans 22 lockfile formats against OSV.dev for both CVEs and MAL-* malicious package advisories. Run it against your package-lock.json before your next deploy.
Encrypting a Database Column
Here's the pattern for encrypting PII in a database. Store the packed buffer as a binary column (or base64 in a text column if your ORM fights you).
import { encrypt, decrypt } from './crypto.js';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'base64');
// Writing
const email = 'user@example.com';
const encrypted = encrypt(email, key);
await db.query(
'INSERT INTO users (id, email_encrypted) VALUES ($1, $2)',
[userId, encrypted]
);
// Reading
const row = await db.query(
'SELECT email_encrypted FROM users WHERE id = $1',
[userId]
);
const email = decrypt(row.email_encrypted, key);
You lose the ability to query by email directly. That's the trade-off. If you need lookup-by-email, store a separate HMAC or a blind index alongside the ciphertext. Don't store the plaintext "for search" -- that defeats the purpose.
The Regulatory Reality
GDPR fines hit 7.1 billion euros total, with 1.2 billion in 2025 alone. Under CCPA, the largest settlement reached $1.55M in July 2025. The statute allows $750 per consumer in damages specifically for breaches involving unencrypted data.
Eight new US state privacy laws took effect in 2025. Three more in 2026. The regulatory direction is clear: encrypt PII at rest, or pay more when (not if) it leaks.
AES-256-GCM on a column takes 30 lines of code. A breach notification takes 30 days and a lot more money.
Quick Reference
A correct AES-256-GCM implementation needs exactly these things:
- 32-byte key from
randomBytes(32), stored in a secrets manager - 12-byte IV from
randomBytes(12), generated fresh for every encryption - Auth tag extracted after
cipher.final(), stored with the ciphertext node:cryptoonly -- no third-party packages
If you get those four things right, you're ahead of most production codebases I've reviewed.
The code from this post is copy-pasteable. Use it. Encrypt the PII column you've been meaning to encrypt. Your future self (and your legal team) will thank you.