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 behind Math.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() or crypto.randomUUID() for tokens, session IDs, and salts — never Math.random().
  • Hash passwords with scrypt, pbkdf2, or the bcrypt package — never plain createHash("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, not scryptSync) 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:crypto already exposes.

Practice Exercises

  • Write a function fileChecksum(path) that streams a file through crypto.createHash("sha256") (using fs.createReadStream and the hash’s update/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) and verifyPayload(obj, signature, secret), that JSON-stringify an object, HMAC it with SHA-256, and verify it using timingSafeEqual. 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 a Map, and a login(username, password) function that verifies it. Confirm a wrong password is rejected and the right one succeeds.

Summary

  • node:crypto is Node’s built-in wrapper around OpenSSL — no installation needed for hashing, HMAC, encryption, or secure randomness.
  • Use createHash for one-way checksums, but never for passwords — it’s far too fast for attackers to brute-force.
  • Use createHmac to prove a message’s authenticity and integrity with a shared secret.
  • Use scrypt (or bcrypt) for password storage — always with a unique random salt per user.
  • Use createCipheriv/createDecipheriv with 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, and timingSafeEqual whenever comparing secrets.
  • Prefer async crypto APIs in servers — the sync versions block the event loop for every other connection.