The Crypto Module
Every real application eventually needs to protect data: hash a password before storing it, sign a message so it can’t be tampered with, generate an unguessable token, or encrypt a secret before writing it to disk. Node.js ships with a built-in node:crypto module that does all of this without installing anything. It is a thin, well-tested wrapper around OpenSSL, the same cryptography library used by browsers and most of the internet’s TLS traffic.
This lesson covers the parts of crypto you will actually use: one-way hashing, HMAC message authentication, cryptographically secure random values, password hashing with scrypt, and symmetric encryption with AES-GCM. It also covers the mistakes that turn ‘I used crypto’ into a security incident.
Overview: How It Works
node:crypto exposes several distinct tools, and it is important not to confuse them:
- Hashing (
createHash) turns data of any size into a fixed-length digest. It is one-way: you cannot recover the input from the digest. Good for checksums and integrity checks, but not for passwords on its own, because it’s too fast — an attacker can try billions of guesses per second on stolen hashes. - HMAC (
createHmac) is a keyed hash: it proves a message came from someone who knows a shared secret, and that the message wasn’t altered. Used for signing webhooks, API requests, and tokens. - Key derivation functions (
scrypt,pbkdf2) are deliberately slow and memory-hard hashes designed for passwords. They make brute-forcing millions of times more expensive than a plain SHA-256 hash would. - Symmetric encryption (
createCipheriv/createDecipheriv) turns plaintext into ciphertext and back using a shared secret key. Unlike hashing, this is reversible by design. - Secure randomness (
randomBytes,randomUUID) generates values from the operating system’s cryptographically secure random number generator (CSPRNG) — not the predictable, non-cryptographic generator behindMath.random().
Under the hood, most of these operations are CPU-bound and synchronous at the OpenSSL level. Node exposes both synchronous versions (like scryptSync) and asynchronous versions (like scrypt) for the expensive ones. The asynchronous versions run on libuv’s thread pool, the same pool used for file system and DNS work, so they don’t block the event loop while the CPU grinds through the algorithm. Cheap operations like createHash and randomBytes for small sizes are fast enough that Node only offers a synchronous-feeling API (though very large random byte requests do have an async form too).
Syntax
The functions you’ll use most share a similar shape: create an object bound to an algorithm and (optionally) a key, feed it data, and read out the result.
const crypto = require("node:crypto");
crypto.createHash(algorithm); // e.g. "sha256"
crypto.createHmac(algorithm, key); // keyed hash
crypto.randomBytes(size); // returns a Buffer
crypto.randomUUID(); // returns a v4 UUID string
crypto.scrypt(password, salt, keylen, cb); // async password KDF
crypto.createCipheriv(algorithm, key, iv); // symmetric encryption
crypto.createDecipheriv(algorithm, key, iv);
| Function | Purpose |
|---|---|
createHash(algo) |
One-way digest of data (SHA-256, SHA-512, etc.) |
createHmac(algo, key) |
Keyed digest that proves authenticity |
randomBytes(size) |
Cryptographically secure random Buffer |
randomUUID() |
Secure random UUID (v4) string |
scrypt(pw, salt, len, cb) |
Slow, memory-hard password hashing |
createCipheriv/createDecipheriv |
Symmetric encrypt/decrypt with an explicit IV |
timingSafeEqual(a, b) |
Constant-time buffer comparison |
Examples
1. Hashing data with SHA-256
const crypto = require("node:crypto");
const data = "Hello, Node.js!";
const hash = crypto.createHash("sha256").update(data).digest("hex");
console.log(`SHA-256: ${hash}`);
SHA-256: 64-character hexadecimal digest (deterministic — same input always produces the same output)
update() feeds data into the hash (you can call it multiple times for streamed data), and digest("hex") finalizes it and returns the result as a hex string. The same input always produces the same 64-character output, which is why hashes are used for checksums and detecting tampering — but never for passwords by themselves, since anyone can compute this instantly.
2. Signing a message with HMAC
const crypto = require("node:crypto");
const secret = "super-secret-key";
const message = "transfer:100:accountA:accountB";
const signature = crypto.createHmac("sha256", secret).update(message).digest("hex");
console.log(`Signature: ${signature}`);
const rebuiltSignature = crypto.createHmac("sha256", secret).update(message).digest("hex");
const matches = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(rebuiltSignature, "hex")
);
console.log(`Signatures match: ${matches}`);
Signature: 64-character hex HMAC (depends on the secret and message)
Signatures match: true
An HMAC requires a shared secret. If a webhook sender signs a payload with the same secret you both know, you can recompute the HMAC on the payload you received and compare it to the one they sent. If they match, the payload wasn’t altered and came from someone who knows the secret. Note the use of timingSafeEqual instead of === — more on why in Common Mistakes.
3. Hashing passwords with scrypt
const crypto = require("node:crypto");
function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString("hex");
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err);
resolve(`${salt}:${derivedKey.toString("hex")}`);
});
});
}
function verifyPassword(password, storedHash) {
return new Promise((resolve, reject) => {
const [salt, key] = storedHash.split(":");
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) return reject(err);
const keyBuffer = Buffer.from(key, "hex");
resolve(crypto.timingSafeEqual(keyBuffer, derivedKey));
});
});
}
async function main() {
const stored = await hashPassword("correct-horse-battery-staple");
console.log("Stored hash starts with:", stored.slice(0, 20) + "...");
console.log("Valid login:", await verifyPassword("correct-horse-battery-staple", stored));
console.log("Invalid login:", await verifyPassword("wrong-password", stored));
}
main();
Stored hash starts with: 32-hex-char-salt...
Valid login: true
Invalid login: false
Every password gets its own random salt so that two users with the same password never produce the same stored hash, which defeats precomputed rainbow-table attacks. The salt isn’t secret — it’s stored alongside the hash — its job is uniqueness, not secrecy. scrypt is deliberately slow and memory-hungry, so even if a database of hashes leaks, brute-forcing them is expensive. In production, consider the well-known bcrypt npm package as an alternative — it handles salting internally and is battle-tested — but scrypt built into core needs no dependency at all.
4. Symmetric encryption with AES-256-GCM
const crypto = require("node:crypto");
function encrypt(text, key) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString("hex"),
authTag: authTag.toString("hex"),
data: encrypted.toString("hex"),
};
}
function decrypt({ iv, authTag, data }, key) {
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
decipher.setAuthTag(Buffer.from(authTag, "hex"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(data, "hex")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
const key = crypto.randomBytes(32);
const payload = encrypt("Meet at the docks at midnight.", key);
console.log("Encrypted payload:", payload);
console.log("Decrypted text:", decrypt(payload, key));
Encrypted payload: { iv: '...', authTag: '...', data: '...' } (hex strings, different every run)
Decrypted text: Meet at the docks at midnight.
AES-256-GCM needs a 32-byte key, a unique 12-byte initialization vector (IV) per encryption, and produces an authentication tag in addition to the ciphertext. The tag lets decrypt detect if the ciphertext was tampered with — call setAuthTag before decipher.final(), and if it doesn’t match, final() throws. You must store or transmit the IV and auth tag alongside the ciphertext; they aren’t secret, but they’re required to decrypt.
Under the Hood: Order of Operations
When you call the async crypto.scrypt(...): (1) Node validates arguments synchronously and immediately returns — your code continues running past that line without waiting; (2) the actual CPU-intensive derivation is handed to libuv’s thread pool (default size 4, controllable via UV_THREADPOOL_SIZE); (3) once a worker thread finishes, the result is queued back onto the event loop’s poll phase; (4) your callback runs there, on the main thread, with the derived key. This is why scrypt (async) doesn’t freeze your server while it works, but scryptSync does — it runs the same expensive computation directly on the main thread, blocking everything else until it finishes.
Common Mistakes
1. Using Math.random() for tokens or secrets
const token = Math.random().toString(36).slice(2);
Math.random() is not cryptographically secure — its internal state can sometimes be predicted from observed output, which makes tokens guessable. Always use the CSPRNG:
const token = crypto.randomBytes(32).toString("hex");
2. Reusing the same IV for multiple encryptions
const iv = Buffer.alloc(16, 0);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
A fixed, hardcoded IV defeats the security guarantees of the cipher mode — encrypting two messages with the same key and IV can leak information about the plaintexts, and in GCM mode reusing an IV can leak the authentication key entirely. Generate a fresh random IV for every single encryption operation, as shown in Example 4:
const iv = crypto.randomBytes(12); // new IV every time, stored with the ciphertext
3. Comparing secrets with ===
if (signature === userSuppliedSignature) {
// vulnerable: string comparison exits early on the first mismatched byte,
// and the tiny timing difference can be measured by an attacker
}
JavaScript’s === on strings short-circuits as soon as it finds a differing character, which leaks timing information an attacker can exploit to guess a secret byte-by-byte over many requests (a timing attack). Use crypto.timingSafeEqual, which always takes the same amount of time regardless of where the buffers differ:
const valid = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(userSuppliedSignature, "hex")
);
Note that timingSafeEqual throws if the two buffers have different lengths, so check lengths (or pad) before calling it if user input length is unpredictable. It’s also worth remembering, more generally, that any synchronous crypto call (scryptSync, pbkdf2Sync) inside an HTTP request handler blocks every other request on that process for as long as it runs — always prefer the callback or promise-returning versions in server code.
Best Practices
- Use
crypto.randomBytes()orcrypto.randomUUID()for tokens, session IDs, and salts — neverMath.random(). - Hash passwords with
scrypt,pbkdf2, or thebcryptpackage — never plaincreateHash("sha256")or MD5/SHA1. - Always use a unique, random IV per encryption operation, and prefer an authenticated mode like AES-256-GCM over plain AES-CBC so tampering is detectable.
- Compare secrets, signatures, and hashes with
crypto.timingSafeEqual, not===. - Keep encryption keys out of source code — load them from environment variables or a secrets manager, never commit them.
- Prefer the asynchronous crypto APIs (
scrypt, notscryptSync) inside request handlers so you don’t block the event loop for other clients. - Never invent your own cipher scheme or roll your own algorithm — stick to well-known, audited primitives that
node:cryptoalready exposes.
Practice Exercises
- Write a function
fileChecksum(path)that streams a file throughcrypto.createHash("sha256")(usingfs.createReadStreamand the hash’supdate/digest, or piping into it) and resolves with the hex digest, without loading the whole file into memory at once. - Build a small pair of functions,
signPayload(obj, secret)andverifyPayload(obj, signature, secret), that JSON-stringify an object, HMAC it with SHA-256, and verify it usingtimingSafeEqual. Test that tampering with even one field of the object makes verification fail. - Extend the password hashing example into a tiny in-memory ‘user store’: a
register(username, password)function that saves a salted scrypt hash in aMap, and alogin(username, password)function that verifies it. Confirm a wrong password is rejected and the right one succeeds.
Summary
node:cryptois Node’s built-in wrapper around OpenSSL — no installation needed for hashing, HMAC, encryption, or secure randomness.- Use
createHashfor one-way checksums, but never for passwords — it’s far too fast for attackers to brute-force. - Use
createHmacto prove a message’s authenticity and integrity with a shared secret. - Use
scrypt(orbcrypt) for password storage — always with a unique random salt per user. - Use
createCipheriv/createDecipherivwith AES-256-GCM for encryption, with a fresh random IV every time and the resulting auth tag verified on decryption. - Use
crypto.randomBytes()/randomUUID()for anything security-sensitive, andtimingSafeEqualwhenever comparing secrets. - Prefer async crypto APIs in servers — the sync versions block the event loop for every other connection.
