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
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()orcrypto.scrypt()with a random 16-byte salt, storesalt: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
| Feature | Hashing (SHA-256 / PBKDF2) | Encryption (AES-256-GCM) |
|---|---|---|
| Reversible? | No | Yes, with key + IV |
| Output size | Fixed (64 hex chars for SHA-256) | Input size + small overhead |
| Needs salt / IV? | Salt for passwords | IV always, fresh per call |
| Auth built in? | No | Yes (16-byte GCM tag) |
| Speed | Fast (PBKDF2 is slow by design) | Fast with AES-NI hardware |
| Use case | Passwords, checksums | Tokens, 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
// 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
// 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
const iv = Buffer.alloc(16, 0); // fixed zero IV - do not do thisReusing 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
if (receivedHmac === expectedHmac) { ... } // timing attackString 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-256header
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
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)); // falseThe 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
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
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 readyA concise answer to help you respond confidently on this topic during an interview.