Skip to main content

How to use the Crypto module in Node.js for hashing and encryption?

Node.js crypto module gives you OpenSSL-backed hashing and encryption built directly into Node.js, no extra packages needed.

Theory

TL;DR

  • Hashing is one-way: data goes in, a fixed-size fingerprint comes out, no way back
  • Encryption is reversible: the same key (plus an IV) locks and unlocks the data
  • Analogy: hashing is a meat grinder (steak in, burger out, no reconstruction); encryption is a lockbox (same key opens it again)
  • For passwords: always hash with PBKDF2 or scrypt, never encrypt
  • crypto.randomBytes() is the only correct source of random values for security work in Node.js

Quick example

js
const crypto = require('crypto'); // One-way hash - compare only, cannot decode const hash = crypto.createHash('sha256') .update('myPassword123') .digest('hex'); // 8d969eef6ecad3c29a3a629280e75283e6e8a794ea1949be2d6204e551b41d5a // Reversible encryption with AES-256-CBC const key = crypto.randomBytes(32); // 256-bit key const iv = crypto.randomBytes(16); // fresh IV every call const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); let encrypted = cipher.update('sensitive data', 'utf8', 'hex'); encrypted += cipher.final('hex'); // Decrypt later: createDecipheriv('aes-256-cbc', key, iv)

createHash gives you a digest you can only compare. createCipheriv gives you data you can recover if you hold the key and IV.

Key difference

Hashing is a one-way transform. You store the digest, discard the original, and later verify by hashing the incoming value and comparing. That works for passwords because you never need to know what the original was. Encryption is a two-way transform. You can recover the original with the right key. That is what you want for tokens or session payloads you need to read back. The rule: hash what you will only ever need to verify; encrypt what you will need to retrieve.

When to use

  • Password storage: crypto.pbkdf2() or crypto.scrypt() with a random 16-byte salt, store salt:hash
  • Data integrity / checksums: createHash('sha256')
  • API tokens / session payloads: AES-256-GCM with a key from an env variable
  • Webhook signature verification: HMAC-SHA256 with timingSafeEqual()
  • Secure random tokens: crypto.randomBytes(32).toString('hex')
  • Unique identifiers: crypto.randomUUID()

Avoid MD5 and SHA-1 for passwords. Both are fast by design, which is exactly the problem. A modern GPU can compute around 10 billion MD5 hashes per second.

Password hashing in depth

Plain createHash('sha256') on a password has one real problem: two users with the same password get the same hash. An attacker with a precomputed rainbow table cracks both at once. A random salt stored alongside the hash fixes this.

PBKDF2 runs HMAC thousands of times on purpose. 310,000 iterations is the current OWASP recommendation for SHA-512. That makes each guess expensive. crypto.scrypt() (available since Node 12) goes further: it is memory-hard, which makes GPU and ASIC attacks significantly harder because each guess requires a large block of RAM, not just CPU cycles.

AES encryption in practice

AES needs a key and an IV (initialization vector). The key is secret. The IV is not secret, but it must be unique for every encrypt call. If you reuse an IV with the same key, an attacker can XOR two ciphertexts and extract information about both plaintexts.

AES-256-GCM is the better choice over AES-256-CBC for most use cases. GCM does encryption and authentication in one pass and produces a 16-byte auth tag. CBC only encrypts. If someone flips a bit in the ciphertext, CBC decryption produces wrong output with no error. GCM throws. I have seen teams spend days debugging corrupted session data before realizing they were using CBC with no integrity check.

HMAC for message authentication

createHmac is createHash plus a secret key. Plain createHash is vulnerable to length extension attacks if you try to use it as a MAC. createHmac is not. GitHub webhooks, Stripe event verification, and most webhook systems use HMAC-SHA256 for exactly this reason.

Always use crypto.timingSafeEqual() when comparing HMACs. A plain === comparison short-circuits at the first mismatched byte. An attacker can measure response time differences to guess the expected value one byte at a time.

Comparison table

FeatureHashing (SHA-256 / PBKDF2)Encryption (AES-256-GCM)
Reversible?NoYes, with key + IV
Output sizeFixed (64 hex chars for SHA-256)Input size + small overhead
Needs salt / IV?Salt for passwordsIV always, fresh per call
Auth built in?NoYes (16-byte GCM tag)
SpeedFast (PBKDF2 is slow by design)Fast with AES-NI hardware
Use casePasswords, checksumsTokens, secrets, session data

How Node.js handles this internally

Node.js crypto binds OpenSSL through a native C++ layer. Hashing calls OpenSSL's SHA implementation directly. PBKDF2 and scrypt run on the libuv thread pool, so they do not block the event loop. AES gets hardware acceleration via AES-NI instructions on Intel and AMD. crypto.randomBytes() reads from the OS CSPRNG (/dev/urandom on Linux, CryptGenRandom on Windows). This is why Math.random() is disqualified for anything security-related: it is deterministic and seedable.

Common mistakes

Mistake 1: Hashing passwords without salt

js
// Same hash for every user with "password123" crypto.createHash('sha256').update('password123').digest('hex');

Rainbow tables precompute millions of hashes. Without a random salt, one lookup exposes every user who shares the same password. Fix: store salt:hash where salt is crypto.randomBytes(16).toString('hex').

Mistake 2: pbkdf2Sync on the main thread

js
// Blocks the event loop for 100ms+ on every login crypto.pbkdf2Sync(password, salt, 310000, 64, 'sha512');

Under any real load, this queues every other request behind each computation. Use async crypto.pbkdf2() with a callback. At 100 concurrent logins, the sync version is a denial-of-service gap.

Mistake 3: Reusing the same IV

js
const iv = Buffer.alloc(16, 0); // fixed zero IV - do not do this

Reusing an IV with the same key breaks AES confidentiality. Generate crypto.randomBytes(16) fresh for every encrypt call and store the IV with the ciphertext.

Mistake 4: createCipher instead of createCipheriv

createCipher derives a weak IV from the key and has been deprecated since Node 10. Always use createCipheriv with an explicit random IV.

Mistake 5: === for hash comparison

js
if (receivedHmac === expectedHmac) { ... } // timing attack

String comparison stops at the first mismatch. An attacker measures response time variance to reconstruct the expected value. Use crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)).

Real-world usage

  • Express + passport-local: PBKDF2 for storing and verifying local user passwords
  • Next.js: AES-GCM for encrypting __Host- session cookies server-side
  • Socket.io: HMAC signs auth tokens before sending them to clients
  • AWS SDK: SHA-256 for S3 ETag verification on multipart uploads
  • GitHub webhooks: HMAC-SHA256 body signature in the x-hub-signature-256 header

Follow-up questions

Q: What is the difference between createHash and createHmac?
A: createHash is a plain digest with no key, vulnerable to length extension attacks. createHmac wraps the hash in HMAC(key, message), which requires the secret to produce a valid signature. Use createHmac for authentication, createHash for checksums.

Q: Why use PBKDF2 instead of plain SHA-256 for passwords?
A: SHA-256 is designed to be fast, which means billions of guesses per second on a GPU. PBKDF2 runs the hash 310,000+ times intentionally to make each guess slow. scrypt is better still because it is also memory-hard.

Q: AES-256-CBC vs AES-256-GCM?
A: CBC only encrypts, it does nothing to verify the ciphertext was not modified. GCM adds a 16-byte authentication tag and throws on tamper. Use GCM for new code.

Q: How do I generate a cryptographically secure random token?
A: crypto.randomBytes(32).toString('hex'). This uses the OS CSPRNG. Math.random() is not acceptable for security tokens.

Q (senior): Streaming encryption of a 10 GB file ran out of memory. Why and how to fix it?
A: The full encrypted output was being buffered in memory before writing. The fix is Node streams: fs.createReadStream('file').pipe(crypto.createCipheriv(...)).pipe(fs.createWriteStream('file.enc')). Chunks flow through OpenSSL without loading the file into memory. Generate a fresh IV per file and prepend it to the output so decryption can read it back.

Examples

Async password hashing and verification

js
const crypto = require('crypto'); async function hashPassword(password) { const salt = crypto.randomBytes(16).toString('hex'); return new Promise((resolve, reject) => { // 310,000 iterations per OWASP recommendation for sha512 crypto.pbkdf2(password, salt, 310000, 64, 'sha512', (err, key) => { if (err) return reject(err); resolve(`${salt}:${key.toString('hex')}`); }); }); } async function verifyPassword(password, stored) { const [salt, hash] = stored.split(':'); return new Promise((resolve, reject) => { crypto.pbkdf2(password, salt, 310000, 64, 'sha512', (err, key) => { if (err) return reject(err); // timingSafeEqual prevents timing-based attacks resolve(crypto.timingSafeEqual(Buffer.from(hash, 'hex'), key)); }); }); } const stored = await hashPassword('hunter2'); console.log(await verifyPassword('hunter2', stored)); // true console.log(await verifyPassword('wrong', stored)); // false

The async version never blocks the event loop. On a server handling 100 concurrent logins, the sync version would stack every request behind each other's 100ms computation.

AES-256-GCM encryption for session data

js
const crypto = require('crypto'); // Load from environment: openssl rand -hex 32 const SECRET_KEY = Buffer.from(process.env.SESSION_KEY, 'hex'); // 32 bytes function encryptSession(data) { const iv = crypto.randomBytes(16); // unique per call const cipher = crypto.createCipheriv('aes-256-gcm', SECRET_KEY, iv); let ciphertext = cipher.update(JSON.stringify(data), 'utf8', 'hex'); ciphertext += cipher.final('hex'); const tag = cipher.getAuthTag().toString('hex'); // 16-byte integrity tag return `${iv.toString('hex')}:${ciphertext}:${tag}`; } function decryptSession(token) { const [ivHex, ciphertext, tagHex] = token.split(':'); const decipher = crypto.createDecipheriv( 'aes-256-gcm', SECRET_KEY, Buffer.from(ivHex, 'hex') ); decipher.setAuthTag(Buffer.from(tagHex, 'hex')); // throws if tampered let plaintext = decipher.update(ciphertext, 'hex', 'utf8'); plaintext += decipher.final('utf8'); return JSON.parse(plaintext); } const token = encryptSession({ userId: 42, role: 'admin' }); console.log(decryptSession(token)); // { userId: 42, role: 'admin' }

The token format is iv:ciphertext:authTag. If anything in the ciphertext changes between encrypt and decrypt, decipher.final() throws before you ever parse the payload.

HMAC webhook signature verification

js
const crypto = require('crypto'); const express = require('express'); const app = express(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; function verifyWebhook(rawBody, signatureHeader) { const expected = 'sha256=' + crypto .createHmac('sha256', WEBHOOK_SECRET) .update(rawBody) .digest('hex'); if (expected.length !== signatureHeader.length) return false; return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader) ); } // express.raw() is required - you must hash raw bytes, not parsed JSON app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-hub-signature-256']; if (!sig || !verifyWebhook(req.body, sig)) { return res.status(401).json({ error: 'Invalid signature' }); } const event = JSON.parse(req.body); // handle event... res.sendStatus(200); });

A common mistake here is using express.json() instead of express.raw(). If the body is parsed first, you hash the re-serialized JSON object rather than the original bytes the sender signed. The signatures will never match.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?