AES encryption protects more data on the planet than any other cipher, and most of it runs through code that gets the details wrong. A 96-bit nonce reused once, an authentication tag thrown away, a password used directly as a key: each mistake quietly turns “encrypted” into “decodable.” This tutorial builds a correct, production-grade AES-256-GCM module in Node.js from scratch in 12 steps, in roughly 45 minutes, using only the built-in crypto module. No third-party dependencies, no hand-waving, every byte accounted for.
By the end you will have a reusable secureBox library that encrypts strings and files, binds context with Additional Authenticated Data, ships a command-line wrapper, and passes a test suite. You will also understand why each line exists, which is the part that separates working AES encryption from code that looks like it works.
Why AES Encryption Still Defines Data Security in 2026
AES (the Advanced Encryption Standard) became a US federal standard in 2001 and never left. It encrypts your TLS sessions, your disk volumes, your password vaults, and the database columns holding customer records. The algorithm itself remains unbroken: there is no practical cryptanalytic attack against AES-256 in 2026. Every real-world break of “AES encryption” traces back to how developers used the cipher, not the cipher itself.
That distinction matters because Node.js makes the wrong thing easy and the right thing only slightly harder. The deprecated crypto.createCipher() function (note: no iv) derived a key from your password with a single unsalted MD5 pass and used a zero IV. It still appears in thousands of Stack Overflow answers. Modern Node.js ships createCipheriv(), which forces you to supply a real key and a real nonce, and it supports GCM, an authenticated mode that detects tampering. This guide uses AES-256 in GCM mode end to end.
Authenticated encryption is the non-negotiable baseline now. Plain AES-CBC encrypts but does not detect modification, which opens the door to padding-oracle and bit-flipping attacks. GCM is an AEAD construction (Authenticated Encryption with Associated Data): it produces ciphertext plus a 128-bit authentication tag, and decryption fails loudly if a single bit of either changed. If you remember one thing from this tutorial, remember that unauthenticated encryption is a bug, not a tradeoff.
AES-256-GCM in One Minute: The Threat Model
Before any code, fix the mental model. AES-256-GCM takes four inputs and returns two outputs. The inputs are a 32-byte key (256 bits), a 12-byte nonce (also called the IV), the plaintext, and optional Additional Authenticated Data. The outputs are ciphertext (same length as plaintext) and a 16-byte authentication tag. To decrypt, the receiver needs the key, the nonce, the ciphertext, the tag, and the exact same AAD.
The single rule that governs GCM security: never reuse a (key, nonce) pair. The key can stay constant for years; the nonce must be unique for every single message encrypted under that key. Reuse the pair once and an attacker can recover the XOR of two plaintexts and, worse, forge the authentication tag for arbitrary messages. The nonce is not secret (you ship it alongside the ciphertext) but it must be unique. This is the failure that sinks most homegrown AES encryption.
| Parameter | Correct value for AES-256-GCM | Why |
|---|---|---|
| Key length | 32 bytes (256 bits) | Defines “AES-256”; from a CSPRNG or a KDF |
| Nonce / IV length | 12 bytes (96 bits) | NIST-recommended GCM nonce size; most interoperable |
| Auth tag length | 16 bytes (128 bits) | Node.js default and strongest standard tag |
| Nonce reuse | Forbidden | Reuse breaks confidentiality and integrity |
| AAD | Optional, authenticated only | Binds context (user ID, version) without encrypting it |
| Cipher string | aes-256-gcm | The exact identifier Node passes to OpenSSL |
A common instinct is to pick a 16-byte nonce because “bigger is safer.” For GCM it is the opposite. A 12-byte nonce is used directly as the counter block; any other length is hashed through GHASH first, which is slower and, for 16 bytes, slightly more error-prone across libraries. Stick to 12 bytes. NIST SP 800-38D, the document that defines GCM, recommends exactly this.
Prerequisites and Versions
Everything here uses Node.js core APIs, so the dependency list is short. The crypto module is built in; you install nothing to encrypt. The only external packages appear later for testing, and even those are optional because Node ships a test runner.
| Tool | Version used here | Notes |
|---|---|---|
| Node.js | 22 LTS (“Jod”) | Works on 20 LTS and newer; node:test is stable on both |
| npm | 10.x (ships with Node 22) | Used only for project scaffolding |
crypto module | Built in | No install; backed by OpenSSL 3.x |
node:test | Built in | Native test runner, no Jest needed |
| OS | Linux, macOS, or Windows | Pure JavaScript, fully portable |
Confirm your runtime before writing a line of crypto code. Anything 20.x or higher has the stable APIs this tutorial relies on, including crypto.scrypt with a configurable maxmem and the native test runner.
$ node --version
v22.14.0
$ npm --version
10.9.2
$ node -e "console.log(require('crypto').getCiphers().includes('aes-256-gcm'))"
true
That last command is the real prerequisite check. If it prints true, your OpenSSL build exposes AES-256-GCM and you are ready. If it prints false, your Node binary was compiled against a stripped OpenSSL, which is rare outside custom embedded builds.
Step 1: Scaffold the Project
Create a clean directory and an ES module project. Using ES modules ("type": "module") keeps the import syntax modern and matches what you will write in a real codebase in 2026.
$ mkdir secure-box && cd secure-box
$ npm init -y
$ npm pkg set type="module"
$ mkdir src test
$ ls
node_modules package.json src test
You now have a project that will hold the encryption module in src/ and the verification suite in test/. No production dependencies will ever land in package.json, which is exactly the point: correct AES encryption in Node needs nothing but the standard library.
Step 2: Generate a 256-Bit Key the Right Way
AES-256 needs exactly 32 bytes of key material, and those bytes must come from a cryptographically secure random source. In Node that source is crypto.randomBytes(), which reads from the operating system CSPRNG. Never use Math.random(), a timestamp, or a passphrase typed directly into the key slot. Create src/keys.js:
// src/keys.js
import { randomBytes } from 'node:crypto';
// Generate a fresh 256-bit (32-byte) AES key.
export function generateKey() {
return randomBytes(32);
}
// Helper: render a key as a portable base64 string for storage.
export function keyToString(key) {
if (key.length !== 32) {
throw new RangeError(`AES-256 key must be 32 bytes, got ${key.length}`);
}
return key.toString('base64');
}
// Helper: load a key back from base64, validating length.
export function keyFromString(encoded) {
const key = Buffer.from(encoded, 'base64');
if (key.length !== 32) {
throw new RangeError(`Decoded key must be 32 bytes, got ${key.length}`);
}
return key;
}
The length guard is not decoration. A truncated or padded key is the kind of bug that produces ciphertext that decrypts fine in your tests (same wrong key both ways) and fails catastrophically in production. Validate at the boundary. Generating a key from the command line looks like this:
$ node -e "import('./src/keys.js').then(m => console.log(m.keyToString(m.generateKey())))"
qz8Yb3yhVQ2u7mE0n1pK4rT6wX9aZ2cD5fH8jL0mN4=
Store that string in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or at minimum an environment variable injected at deploy time). Never commit a key to git, and never log it. If a raw AES key reaches your application logs, treat every record it ever encrypted as compromised.
Step 3: Derive a Key From a Password With scrypt
Random keys are ideal, but sometimes the secret is a human password (a CLI tool a user unlocks, a client-side vault). You cannot feed a password straight into AES: it is the wrong length and far too low in entropy. Run it through a memory-hard Key Derivation Function first. OWASP recommends scrypt or Argon2id; Node ships scrypt natively. Create src/derive.js:
// src/derive.js
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
// scrypt cost parameters. N is the CPU/memory cost (power of two).
// OWASP suggests N >= 2^17 for password storage; we use 2^15 here as a
// balanced default and raise maxmem so Node does not reject the request.
const COST = { N: 2 ** 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };
// Derive a 32-byte AES key from a password and a 16-byte salt.
export function deriveKey(password, salt) {
return scryptSync(password, salt, 32, COST);
}
// Create a fresh salt for a new secret.
export function newSalt() {
return randomBytes(16);
}
// Constant-time comparison, useful when verifying derived values.
export function safeEqual(a, b) {
return a.length === b.length && timingSafeEqual(a, b);
}
Two details carry the security here. First, the salt: it is unique and random per secret, stored in the clear next to the ciphertext, and it stops attackers from precomputing keys for common passwords. Second, the cost parameters: N sets how much CPU and memory the derivation burns. Higher N means a slower attacker, but also a slower user. The default Node maxmem is 32 MB, which N = 2^15 with r = 8 exceeds, so we raise maxmem explicitly. For high-value password storage, push N to 2^17 and set maxmem accordingly.
Step 4: Encrypt With createCipheriv and a 96-Bit Nonce
Now the core. Create src/cipher.js and write the encrypt function. The flow is fixed: generate a fresh 12-byte nonce, build the cipher with createCipheriv, feed plaintext through update and final, then pull the authentication tag with getAuthTag.
// src/cipher.js
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGO = 'aes-256-gcm';
const NONCE_BYTES = 12; // 96-bit nonce, the GCM standard
const TAG_BYTES = 16; // 128-bit authentication tag
// Encrypt a Buffer or string. Returns { nonce, tag, ciphertext } as Buffers.
export function encrypt(key, plaintext, aad = null) {
if (key.length !== 32) {
throw new RangeError('Key must be 32 bytes for AES-256-GCM');
}
const nonce = randomBytes(NONCE_BYTES);
const cipher = createCipheriv(ALGO, key, nonce, { authTagLength: TAG_BYTES });
if (aad) cipher.setAAD(aad); // must come before update()
const data = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf8');
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag(); // only valid after final()
return { nonce, tag, ciphertext };
}
Order is everything in this function. The fresh randomBytes(12) on every call is what guarantees nonce uniqueness, the rule from the threat model. setAAD must run before any update call or Node throws. And getAuthTag only returns a valid tag after final has run, because GCM computes the tag over the entire message. Pulling the tag early gives you garbage that will never verify.
Step 5: Decrypt and Verify With setAuthTag
Decryption mirrors encryption with one extra, critical call: setAuthTag. In GCM, supplying the tag is what arms integrity checking. If the ciphertext, nonce, AAD, or tag was tampered with, final throws and you must treat the data as forged. Add this to src/cipher.js:
// src/cipher.js (continued)
// Decrypt. Throws if authentication fails (tampering or wrong key).
export function decrypt(key, { nonce, tag, ciphertext }, aad = null) {
if (key.length !== 32) {
throw new RangeError('Key must be 32 bytes for AES-256-GCM');
}
const decipher = createDecipheriv(ALGO, key, nonce, { authTagLength: TAG_BYTES });
if (aad) decipher.setAAD(aad);
decipher.setAuthTag(tag); // arm integrity verification
// final() throws "Unsupported state or unable to authenticate data"
// if the tag does not match. Never swallow this error.
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
Resist the temptation to wrap final() in a try/catch that returns partial plaintext. A GCM authentication failure means an attacker may have modified the message; the only safe response is to discard everything and treat the call as an error. Round-tripping a message confirms the wiring:
$ node --input-type=module -e "
import { generateKey } from './src/keys.js';
import { encrypt, decrypt } from './src/cipher.js';
const key = generateKey();
const box = encrypt(key, 'attack at dawn');
console.log('nonce', box.nonce.length, 'tag', box.tag.length, 'ct', box.ciphertext.length);
console.log('plaintext:', decrypt(key, box).toString());
"
nonce 12 tag 16 ct 14
plaintext: attack at dawn
The ciphertext is 14 bytes, exactly the plaintext length, which confirms GCM is a stream-style mode with no padding. The nonce is 12 bytes and the tag is 16, matching the parameters from the table above.
Step 6: Bind Context With Additional Authenticated Data
AAD is the most underused feature of AEAD ciphers. It lets you authenticate data without encrypting it, which means you can cryptographically bind a ciphertext to its context. Imagine encrypting a session token for user 4821. If you pass the user ID as AAD, an attacker who swaps that ciphertext into user 9999’s record will trigger an authentication failure, because the AAD no longer matches.
$ node --input-type=module -e "
import { generateKey } from './src/keys.js';
import { encrypt, decrypt } from './src/cipher.js';
const key = generateKey();
const aad = Buffer.from('user:4821|v:2');
const box = encrypt(key, 'session-secret', aad);
// Correct AAD decrypts fine:
console.log('ok:', decrypt(key, box, aad).toString());
// Wrong AAD (different user) fails authentication:
try { decrypt(key, box, Buffer.from('user:9999|v:2')); }
catch (e) { console.log('blocked:', e.message); }
"
ok: session-secret
blocked: Unsupported state or unable to authenticate data
Use AAD for anything that frames the ciphertext: a record ID, a schema version, a tenant identifier. It costs nothing in ciphertext size and turns a whole class of cut-and-paste attacks into hard authentication failures. Just remember that AAD is not encrypted; never put a secret in it.
Step 7: Package the Ciphertext Envelope
Right now encrypt returns three separate buffers. For storage and transport you want one self-describing blob. The standard pattern is an envelope: concatenate nonce || tag || ciphertext and base64 the result. Because the nonce and tag are fixed lengths, the decoder can slice them back out without any delimiter. Add to src/cipher.js:
// src/cipher.js (continued)
// Pack { nonce, tag, ciphertext } into one base64 string.
export function seal(parts) {
return Buffer.concat([parts.nonce, parts.tag, parts.ciphertext]).toString('base64');
}
// Reverse of seal(): slice fixed-length nonce and tag off the front.
export function open(blob) {
const buf = Buffer.from(blob, 'base64');
if (buf.length < NONCE_BYTES + TAG_BYTES) {
throw new RangeError('Envelope too short to be valid');
}
return {
nonce: buf.subarray(0, NONCE_BYTES),
tag: buf.subarray(NONCE_BYTES, NONCE_BYTES + TAG_BYTES),
ciphertext: buf.subarray(NONCE_BYTES + TAG_BYTES),
};
}
This envelope is the single string you store in a database column or send over the wire. It is fully portable: a Go, Python, or Rust service that knows the same layout (12-byte nonce, 16-byte tag, rest is ciphertext) can decrypt it. Many production bugs come from shipping the ciphertext but forgetting the nonce or tag. The envelope makes that impossible because all three travel together.
Step 8: Assemble the Complete secureBox Module
Tie the pieces into one clean public API. Create src/secure-box.js, the file you would actually import in an application. It offers password-based and key-based encryption and always returns a portable envelope.
// src/secure-box.js
import { encrypt, decrypt, seal, open } from './cipher.js';
import { deriveKey, newSalt } from './derive.js';
// Encrypt with a raw 32-byte key. Returns a base64 envelope string.
export function encryptWithKey(key, plaintext, aad = null) {
return seal(encrypt(key, plaintext, aad));
}
export function decryptWithKey(key, blob, aad = null) {
return decrypt(key, open(blob), aad).toString('utf8');
}
// Encrypt with a password. Salt is prepended so decryption is self-contained.
export function encryptWithPassword(password, plaintext, aad = null) {
const salt = newSalt();
const key = deriveKey(password, salt);
const envelope = encrypt(key, plaintext, aad);
// Final blob: salt(16) || nonce(12) || tag(16) || ciphertext
return Buffer.concat([salt, Buffer.from(seal(envelope), 'base64')]).toString('base64');
}
export function decryptWithPassword(password, blob, aad = null) {
const buf = Buffer.from(blob, 'base64');
const salt = buf.subarray(0, 16);
const key = deriveKey(password, salt);
return decrypt(key, open(buf.subarray(16).toString('base64')), aad).toString('utf8');
}
This is the complete working project’s heart. The password variant prepends the 16-byte salt so the blob is fully self-describing: anyone with the password can decrypt it without out-of-band parameters. The key variant is faster and is what you use when a real key lives in your secrets manager. A quick smoke test:
$ node --input-type=module -e "
import { encryptWithPassword, decryptWithPassword } from './src/secure-box.js';
const blob = encryptWithPassword('correct horse battery staple', 'top secret memo');
console.log('envelope:', blob.slice(0, 32) + '...');
console.log('decrypted:', decryptWithPassword('correct horse battery staple', blob));
"
envelope: 3Qe7yK1mX0p9aZ2bC4dE6fG8hJ0kL2mN...
decrypted: top secret memo
Step 9: Encrypt Large Files With Streams
Loading a 4 GB video into a single Buffer to encrypt it will exhaust memory. The cipher object is a Node transform stream, so you pipe data through it instead. Create src/file.js. The nonce goes at the front of the output file; the auth tag goes at the end, because GCM only knows the tag once the whole stream has passed.
// src/file.js
import { createReadStream, createWriteStream } from 'node:fs';
import { open as openFile } from 'node:fs/promises';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { pipeline } from 'node:stream/promises';
const ALGO = 'aes-256-gcm';
export async function encryptFile(key, inPath, outPath) {
const nonce = randomBytes(12);
const cipher = createCipheriv(ALGO, key, nonce, { authTagLength: 16 });
const out = createWriteStream(outPath);
out.write(nonce); // 12-byte header
await pipeline(createReadStream(inPath), cipher, out, { end: false });
out.write(cipher.getAuthTag()); // 16-byte trailer
out.end();
}
export async function decryptFile(key, inPath, outPath) {
const fh = await openFile(inPath);
const stat = await fh.stat();
const nonce = Buffer.alloc(12);
const tag = Buffer.alloc(16);
await fh.read(nonce, 0, 12, 0); // first 12 bytes
await fh.read(tag, 0, 16, stat.size - 16); // last 16 bytes
await fh.close();
const decipher = createDecipheriv(ALGO, key, nonce, { authTagLength: 16 });
decipher.setAuthTag(tag);
// Read only the ciphertext slice: skip 12-byte header, drop 16-byte trailer.
const body = createReadStream(inPath, { start: 12, end: stat.size - 17 });
await pipeline(body, decipher, createWriteStream(outPath));
}
Streaming keeps memory flat regardless of file size, but it changes the security story in one way: a streaming decryptor emits plaintext chunks before it has verified the final tag. For most use cases you write to a temporary file and only rename it into place after pipeline resolves without error, so unverified plaintext never reaches a consumer. For extreme threat models, chunk the file and authenticate each chunk separately.
Step 10: Add a Command-Line Wrapper
A CLI turns the library into a usable tool. Create src/cli.js using only Node’s built-in parseArgs, so there are still zero dependencies.
// src/cli.js
import { parseArgs } from 'node:util';
import { encryptWithPassword, decryptWithPassword } from './secure-box.js';
const { values } = parseArgs({
options: {
mode: { type: 'string', short: 'm' }, // 'enc' or 'dec'
text: { type: 'string', short: 't' },
pass: { type: 'string', short: 'p' },
},
});
if (!values.mode || !values.text || !values.pass) {
console.error('Usage: node src/cli.js -m enc|dec -p PASSWORD -t TEXT');
process.exit(1);
}
const result = values.mode === 'enc'
? encryptWithPassword(values.pass, values.text)
: decryptWithPassword(values.pass, values.text);
console.log(result);
$ node src/cli.js -m enc -p hunter2 -t "launch codes"
9pX2...base64-envelope...Q==
$ node src/cli.js -m dec -p hunter2 -t "9pX2...base64-envelope...Q=="
launch codes
One caveat for real deployments: passing a password as a command-line argument leaks it into shell history and the process list. For anything beyond local testing, read the password from an environment variable or an interactive prompt instead of -p. The example uses a flag purely to keep the demo self-contained.
Step 11: Write Tests and Verify Tamper Detection
Crypto code that is not tested for tamper detection is not finished. The most important test is the negative one: flip a byte and confirm decryption throws. Create test/secure-box.test.js using the native node:test runner.
// test/secure-box.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { generateKey } from '../src/keys.js';
import { encrypt, decrypt } from '../src/cipher.js';
import { encryptWithPassword, decryptWithPassword } from '../src/secure-box.js';
test('round-trips plaintext with a raw key', () => {
const key = generateKey();
const box = encrypt(key, 'hello world');
assert.equal(decrypt(key, box).toString(), 'hello world');
});
test('every encryption uses a unique nonce', () => {
const key = generateKey();
const a = encrypt(key, 'same input');
const b = encrypt(key, 'same input');
assert.notDeepEqual(a.nonce, b.nonce);
assert.notDeepEqual(a.ciphertext, b.ciphertext); // nonce randomizes output
});
test('a flipped ciphertext byte fails authentication', () => {
const key = generateKey();
const box = encrypt(key, 'integrity matters');
box.ciphertext[0] ^= 0x01; // tamper
assert.throws(() => decrypt(key, box));
});
test('wrong AAD is rejected', () => {
const key = generateKey();
const box = encrypt(key, 'data', Buffer.from('ctx-1'));
assert.throws(() => decrypt(key, box, Buffer.from('ctx-2')));
});
test('password round-trip works', () => {
const blob = encryptWithPassword('pw', 'secret');
assert.equal(decryptWithPassword('pw', blob), 'secret');
});
$ node --test
✓ round-trips plaintext with a raw key (2.1ms)
✓ every encryption uses a unique nonce (0.9ms)
✓ a flipped ciphertext byte fails authentication (1.4ms)
✓ wrong AAD is rejected (1.1ms)
✓ password round-trip works (88.0ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
The password test is slower (88 ms) because scrypt deliberately burns CPU and memory; that cost is a feature, not a regression. The tamper test is the one that proves your AES encryption is actually authenticated. If you ever refactor the cipher and that test stops throwing, you have shipped a silent integrity hole.
Step 12: Five Common Pitfalls That Break AES Encryption
The algorithm is sound; the misuse is where data leaks. These five mistakes account for the overwhelming majority of broken AES deployments in the wild. Each one is invisible in a happy-path test and fatal in production.
| Pitfall | Why it is dangerous | Fix in this project |
|---|---|---|
| Reusing a (key, nonce) pair | Reveals plaintext XOR and allows tag forgery | Fresh randomBytes(12) per encrypt call |
Using createCipher() (no IV) | Unsalted MD5 key derivation, zero IV, deprecated | Always use createCipheriv() |
| Discarding the auth tag | Decryption cannot detect tampering | Store tag in the envelope, call setAuthTag() |
| Password used directly as key | Low entropy, wrong length, no salt | Run scrypt via deriveKey() |
| Choosing CBC or ECB mode | No integrity; ECB leaks patterns outright | Use GCM, an authenticated mode |
The ECB row deserves a special warning. ECB mode encrypts identical plaintext blocks to identical ciphertext blocks, which famously leaves the outline of an image visible even after “encryption.” It offers no semantic security and has no legitimate use for application data. If a library or tutorial defaults to aes-256-ecb, walk away.
Troubleshooting AES-GCM in Node.js
When AES encryption fails in Node, the error messages are terse and the cause is usually a parameter mismatch. This table maps the symptoms you will actually see to their real causes.
| Symptom / error | Likely cause | Resolution |
|---|---|---|
Unsupported state or unable to authenticate data | Tag, key, nonce, or AAD mismatch (or real tampering) | Verify all four match the encrypt side exactly |
Invalid key length | Key is not 32 bytes | Check base64 decoding; AES-256 needs 32 bytes |
Invalid IV length | Nonce is not 12 bytes | Use randomBytes(12), not 16 |
setAAD throws | Called after update() | Set AAD before the first update() |
getAuthTag returns wrong value | Called before final() | Call final() first, then getAuthTag() |
| Decryption succeeds but text is garbled | Wrong key that is still 32 bytes | Confirm key source and encoding match |
ERR_CRYPTO_INVALID_STATE | Reusing a cipher object after final() | Create a new cipher per message |
scrypt throws memory limit exceeded | N too high for default maxmem | Raise maxmem in the cost options |
| Output differs across languages | Different nonce/tag layout in the envelope | Agree on nonce || tag || ciphertext |
The first row is by far the most common support ticket. “Unable to authenticate data” almost never means OpenSSL is broken; it means one of the four GCM inputs differs by a byte between encrypt and decrypt. Log the lengths of your key, nonce, tag, and AAD on both sides and the mismatch reveals itself fast.
Advanced Tips for Production AES Encryption
Rotate Keys Without Re-Encrypting Everything
Prepend a one-byte key ID to your envelope. When you rotate to a new key, encrypt new data with key 2 while still decrypting old data with key 1, selected by that ID. This lets you retire a compromised key gradually instead of running a risky bulk re-encryption. The pattern is called envelope encryption and underpins every cloud KMS.
Watch the 96-Bit Nonce Birthday Bound
Random 96-bit nonces are safe until you approach roughly 2^32 messages under one key, where the birthday paradox makes a collision plausible. For most applications that is billions of messages away. If a single key will encrypt more than a few billion messages, switch to a deterministic counter nonce or adopt AES-GCM-SIV, which is nonce-misuse resistant. Know your message volume per key.
Prefer a Vetted Library When You Can
This tutorial teaches the primitives so you understand what your stack does, but in greenfield code consider libsodium’s crypto_secretbox (via the sodium-native binding). It picks safe defaults, hides the nonce footguns, and uses XChaCha20-Poly1305 with a 192-bit nonce that all but eliminates reuse risk. AES-256-GCM remains the right call when you need hardware acceleration (AES-NI) or interoperability with systems that only speak AES.
AES-256-GCM vs Other Symmetric Options
GCM is the default for a reason, but it is not the only authenticated cipher. This comparison frames when to reach for each.
| Construction | Nonce size | Best for | Watch out for |
|---|---|---|---|
| AES-256-GCM | 96 bits | Hardware-accelerated, interoperable AEAD | Catastrophic nonce reuse |
| AES-256-GCM-SIV | 96 bits | High message volume, reuse resistance | Slightly slower; less ubiquitous |
| ChaCha20-Poly1305 | 96 bits | Software without AES-NI, mobile | Still nonce-sensitive |
| XChaCha20-Poly1305 | 192 bits | Random nonces at scale (libsodium) | Not in Node core; needs a binding |
| AES-256-CBC + HMAC | 128-bit IV | Legacy interop only | Easy to implement wrong; avoid for new code |
For Node.js services in 2026, the practical choice narrows to two: AES-256-GCM when you control nonces and want AES-NI speed, or an XChaCha20-Poly1305 binding when you want random nonces with a comfortable safety margin. Everything in this tutorial uses GCM because it ships in Node core and matches what TLS, cloud KMS, and most databases speak.
Frequently Asked Questions
Is AES-256 overkill compared to AES-128?
Both are secure against classical attacks today. AES-256 carries a larger margin against future cryptanalysis and against quantum search (Grover’s algorithm roughly halves the effective key strength, leaving AES-256 at a comfortable 128-bit equivalent). The performance difference is small, so AES-256 is the sensible default for new systems.
Can I reuse the same nonce if the key is different?
Yes. The rule forbids reusing a (key, nonce) pair. A given nonce is fine with a different key. In practice, generating a fresh random nonce per message is simpler and safer than tracking which nonces were used with which keys, which is exactly what the encrypt function does.
Do I need to keep the nonce and tag secret?
No. The nonce and authentication tag are not secret and are designed to be stored or transmitted alongside the ciphertext. Only the key is secret. That is why the envelope in Step 7 safely concatenates nonce, tag, and ciphertext into one public blob.
Why does decryption throw instead of returning false?
Node surfaces a GCM authentication failure as a thrown error on final(). This is intentional: a failed tag check means the data may be forged, and throwing forces you to handle it rather than silently process attacker-controlled bytes. Always let the error propagate and treat the message as invalid.
Is the built-in crypto module fast enough for production?
Yes. Node’s crypto is backed by OpenSSL, which uses AES-NI hardware instructions on virtually all server CPUs. It encrypts gigabytes per second and outperforms most pure-JavaScript libraries by a wide margin. There is no performance reason to reach for a third-party AES implementation.
Will quantum computers break AES encryption?
Not in the way they threaten RSA and elliptic-curve cryptography. Symmetric ciphers like AES face only Grover’s algorithm, which provides a quadratic speedup. AES-256 retains an effective 128-bit security level against that, which remains far out of reach. The urgent quantum migration is for public-key algorithms, not AES.
How should I store the AES key itself?
Never in source code or a config file committed to git. Use a dedicated secrets manager (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault) or a hardware-backed KMS that performs encryption without exposing the raw key to your process at all. At minimum, inject the key through an environment variable set at deploy time.
Final Verification Checklist
Run through this before shipping any AES encryption built on the pattern above. Each item maps to a step in this tutorial and to a specific class of real-world failure.
- The cipher string is
aes-256-gcm, the key is 32 bytes, the nonce is 12 bytes. - A fresh nonce is generated for every single encryption.
- The 16-byte auth tag is stored and supplied on decrypt via
setAuthTag(). - Decryption errors propagate; no try/catch silently returns partial plaintext.
- Passwords pass through scrypt with a unique salt, never used as keys directly.
- The tamper test (flip a byte, expect a throw) is green in CI.
- Keys live in a secrets manager, never in git or logs.
- AAD binds ciphertexts to their context where context exists.
Tick every box and you have AES encryption that is correct in the ways that actually matter: authenticated, nonce-safe, key-derived, and tested against tampering. That is a higher bar than most production code clears, and you reached it with zero dependencies and about 200 lines of Node.
Related Coverage
- Argon2 Password Hashing in Node.js: 11 Steps
- SHA-256 Explained: How It Works and Why It Matters
- Digital Signatures Explained: How They Work and Why Hashes Matter
- HTTPS and TLS Explained: What the Padlock Really Means
- Post-Quantum Cryptography: 50% of Web Now Safe
- Hashing and Cryptography Explained (cryptography hub)
External references: the Node.js crypto documentation, the OWASP Cryptographic Storage Cheat Sheet, NIST SP 800-38D defining GCM, RFC 5116 on authenticated encryption, and the libsodium secretbox reference.




