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.

ParameterCorrect value for AES-256-GCMWhy
Key length32 bytes (256 bits)Defines “AES-256”; from a CSPRNG or a KDF
Nonce / IV length12 bytes (96 bits)NIST-recommended GCM nonce size; most interoperable
Auth tag length16 bytes (128 bits)Node.js default and strongest standard tag
Nonce reuseForbiddenReuse breaks confidentiality and integrity
AADOptional, authenticated onlyBinds context (user ID, version) without encrypting it
Cipher stringaes-256-gcmThe 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.

ToolVersion used hereNotes
Node.js22 LTS (“Jod”)Works on 20 LTS and newer; node:test is stable on both
npm10.x (ships with Node 22)Used only for project scaffolding
crypto moduleBuilt inNo install; backed by OpenSSL 3.x
node:testBuilt inNative test runner, no Jest needed
OSLinux, macOS, or WindowsPure 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.

PitfallWhy it is dangerousFix in this project
Reusing a (key, nonce) pairReveals plaintext XOR and allows tag forgeryFresh randomBytes(12) per encrypt call
Using createCipher() (no IV)Unsalted MD5 key derivation, zero IV, deprecatedAlways use createCipheriv()
Discarding the auth tagDecryption cannot detect tamperingStore tag in the envelope, call setAuthTag()
Password used directly as keyLow entropy, wrong length, no saltRun scrypt via deriveKey()
Choosing CBC or ECB modeNo integrity; ECB leaks patterns outrightUse 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 / errorLikely causeResolution
Unsupported state or unable to authenticate dataTag, key, nonce, or AAD mismatch (or real tampering)Verify all four match the encrypt side exactly
Invalid key lengthKey is not 32 bytesCheck base64 decoding; AES-256 needs 32 bytes
Invalid IV lengthNonce is not 12 bytesUse randomBytes(12), not 16
setAAD throwsCalled after update()Set AAD before the first update()
getAuthTag returns wrong valueCalled before final()Call final() first, then getAuthTag()
Decryption succeeds but text is garbledWrong key that is still 32 bytesConfirm key source and encoding match
ERR_CRYPTO_INVALID_STATEReusing a cipher object after final()Create a new cipher per message
scrypt throws memory limit exceededN too high for default maxmemRaise maxmem in the cost options
Output differs across languagesDifferent nonce/tag layout in the envelopeAgree 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.

ConstructionNonce sizeBest forWatch out for
AES-256-GCM96 bitsHardware-accelerated, interoperable AEADCatastrophic nonce reuse
AES-256-GCM-SIV96 bitsHigh message volume, reuse resistanceSlightly slower; less ubiquitous
ChaCha20-Poly130596 bitsSoftware without AES-NI, mobileStill nonce-sensitive
XChaCha20-Poly1305192 bitsRandom nonces at scale (libsodium)Not in Node core; needs a binding
AES-256-CBC + HMAC128-bit IVLegacy interop onlyEasy 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.

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.