New to Rust? Grab our free Rust for Beginners eBook Get it free →
Encrypt and Decrypt Data in Node.js Using Crypto: A Step-by-Step Guide

Node.js signs every response, stores session data, and saves user records as plain bytes. If anyone reads those bytes from a database dump or a log file, they read your data. The crypto module that ships with Node.js lets you turn those bytes into ciphertext that only your key can open, and back again when your code needs the original value.
Every sample below ran on Node v26.7.0 before publication. You will encrypt and decrypt a string with AES-256-CBC, do the same for raw binary buffers, and finish with authenticated encryption that detects tampering.
How AES encryption works in Node.js
AES (Advanced Encryption Standard) is a symmetric cipher: the same secret key encrypts and decrypts. The 256 in AES-256 is the key length in bits, which means a 32-byte key.
The crypto module is built into Node.js, so there is nothing to install and no npm package to add.
If an older tutorial tells you to run npm install crypto, skip that step. The package published under that name on npm is an abandoned wrapper around the built-in module.
CBC (Cipher Block Chaining) mode needs one more input besides the key: an initialization vector (IV), a random 16-byte value that makes the same plaintext produce different ciphertext each time. The IV is not a secret. You store it next to the ciphertext and both are required for decryption.
One decision matters more than any line of code below: where the key comes from. A key generated with randomBytes() exists only until the process exits, so anything encrypted today becomes unreadable after a restart unless you persist it securely. Deriving the key from a passphrase with scrypt keeps ciphertext decodable across runs while keeping the passphrase out of the codebase.
Derive a stable key with scrypt
The salt does not need to be secret. What matters is that each application uses its own value so identical passphrases still produce different keys on different systems, and that scryptSync receives the passphrase from the environment rather than the source file.
import crypto from 'node:crypto';
const password = process.env.APP_SECRET ?? 'a long random passphrase';
const salt = Buffer.from('codeforgeek-demo-salt');
const key = crypto.scryptSync(password, salt, 32); // 32 bytes for aes-256

Encrypt and decrypt a string with AES-256-CBC
Create a folder, save the next file as cbc_string.mjs, and run it with node. The script defines two functions: encrypt returns an object holding the IV and the hex-encoded ciphertext, decrypt reverses that exact structure.
import crypto from 'node:crypto';
// Derive a stable key from a passphrase so ciphertext stays decodable across runs.
const password = process.env.APP_SECRET ?? 'a long random passphrase';
const salt = Buffer.from('codeforgeek-demo-salt');
const key = crypto.scryptSync(password, salt, 32); // 32 bytes for aes-256
function encrypt(text) {
const iv = crypto.randomBytes(16); // fresh IV per message
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return { iv: iv.toString('hex'), data: encrypted.toString('hex') };
}
function decrypt(payload) {
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
key,
Buffer.from(payload.iv, 'hex')
);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(payload.data, 'hex')),
decipher.final(),
]);
return decrypted.toString('utf8');
}
const secret = 'Username & Password';
const encrypted = encrypt(secret);
console.log('Encrypted:', JSON.stringify(encrypted));
console.log('Decrypted:', decrypt(encrypted));
Run it and you see a fresh hex payload on every execution plus the original string recovered at the end.
The cipher.update call feeds plaintext into the cipher and cipher.final flushes the last padded block. That is why both parts are concatenated into one buffer before the function returns.
Most bugs with this API come down to two details. First, the IV must be random per message: reusing it with the same key weakens CBC badly enough that the mode’s own specification forbids it. Second, decryption fails loudly when either the key, the IV, or the ciphertext changes, usually ending in a failed decipher.final() call rather than wrong output.
Encrypt and decrypt a Buffer
Strings are just one input type. cipher.update accepts a Buffer directly, which is what you want for file bytes, images, or any binary payload. The same key derivation applies.
import crypto from 'node:crypto';
const password = 'a long random passphrase';
const key = crypto.scryptSync(password, Buffer.from('codeforgeek-demo-salt'), 32);
function encryptBuffer(buf) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
return {
iv: iv.toString('hex'),
data: Buffer.concat([cipher.update(buf), cipher.final()]),
};
}
function decryptBuffer(payload) {
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
key,
Buffer.from(payload.iv, 'hex')
);
return Buffer.concat([decipher.update(payload.data), decipher.final()]);
}
const fileBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const sealed = encryptBuffer(fileBytes);
console.log('Ciphertext bytes:', sealed.data.length);
console.log('Round-trip equal:', decryptBuffer(sealed).equals(fileBytes));
This sample starts from eight arbitrary binary bytes, the signature every PNG file begins with, and proves the round trip with Buffer.equals instead of comparing strings. That check matters because a lossy encryption scheme can still produce plausible-looking output.

Prefer AES-256-GCM when integrity matters
CBC confirms nothing about who created the ciphertext. An attacker who flips a bit inside a CBC payload gets corrupted plaintext, not an error, and in some formats that corruption is exploitable. GCM (Galois/Counter Mode) is an authenticated mode: encryption also produces an auth tag, and decryption refuses to run unless the tag verifies.
Use GCM whenever both sides of the exchange are under your control.
Three differences from the CBC version: the nonce is 12 bytes, cipher.getAuthTag() captures the tag after final(), and decipher.setAuthTag() must be called before decryption.
import crypto from 'node:crypto';
const password = 'a long random passphrase';
const key = crypto.scryptSync(password, Buffer.from('codeforgeek-demo-salt'), 32);
function encryptGCM(text) {
const iv = crypto.randomBytes(12); // GCM wants a 12-byte nonce
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return {
iv: iv.toString('hex'),
data: encrypted.toString('hex'),
tag: cipher.getAuthTag().toString('hex'),
};
}
function decryptGCM(payload) {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(payload.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(payload.tag, 'hex'));
return Buffer.concat([
decipher.update(Buffer.from(payload.data, 'hex')),
decipher.final(),
]).toString('utf8');
}
const sealed = encryptGCM('transfer ₹500 to account 4482');
console.log('Sealed:', JSON.stringify(sealed));
console.log('Opened:', decryptGCM(sealed));
// Tamper one byte of the ciphertext: GCM must refuse to open it.
sealed.data = sealed.data.slice(0, -2) + 'ff';
try {
decryptGCM(sealed);
} catch (err) {
console.log('Tamper attempt ->', err.code ?? err.message);
}
The tamper test at the end replaces the last byte of the ciphertext with ff and attempts to open it. On my run Node threw Unsupported state or unable to authenticate data, which is exactly the behavior you want: modified ciphertext never turns into trusted plaintext.

Where the key should live
Never commit the passphrase. Load it from an environment variable or a secrets manager, exactly as process.env.APP_SECRET does above. Store the IV alongside the ciphertext, keep the auth tag with GCM payloads, and treat any key that cannot be rotated as a liability: rotation means re-encrypting stored data under a new key, so design your storage with a key identifier next to each record.
Wrapping up
You now have three working approaches: stable-key CBC for legacy compatibility, buffer handling for binary data, and authenticated GCM for anything an attacker could touch. For new work, start with GCM and derive keys with scrypt. The full API surface, including streaming ciphers for large files, lives in the official Node.js crypto documentation.
If you are securing a login flow next, my guide to JWT authentication with refresh tokens covers the token side of the problem.
Is the crypto module built into Node.js?
Yes. It ships with Node.js and requires no npm install. The crypto package on npm is an unrelated third-party wrapper.
What key length does aes-256-cbc require?
A 32-byte key plus a random 16-byte initialization vector for each encrypted message.
Why does decryption fail after restarting the app?
Because the key was generated with randomBytes at runtime and no longer exists. Derive the key from a persisted passphrase using scryptSync so ciphertext stays decodable across restarts.
When should I use AES-GCM instead of CBC?
Use GCM whenever you control both sides of the exchange. Its auth tag makes decryption fail when ciphertext has been modified, while CBC silently returns corrupted plaintext.




