crypto
Hashing, Encryption, and Secure Tokens
LinkedIn Hook
"Your password hashing function runs in 0.2 milliseconds. That is not a feature. That is a vulnerability."
Most Node.js developers reach for
crypto.createHash('sha256')the moment they need to store a password. It is fast, built-in, and feels secure. It is none of those things — at least not in the way that matters.SHA-256 was designed for integrity, not for password storage. A modern GPU can compute billions of SHA-256 hashes per second. If your database leaks, an attacker cracks every weak password in your system before the breach makes the news. The very speed that makes SHA great for file checksums makes it catastrophic for passwords.
The fix is slow on purpose. Algorithms like bcrypt, scrypt, and argon2 deliberately burn CPU and memory so that each guess costs the attacker real money. Combine that with HMAC for webhook verification, AES-256-GCM for symmetric encryption,
randomBytesfor tokens, andtimingSafeEqualfor comparison — and you have the toolkit every Node backend needs.In Lesson 3.5, I break down the entire
cryptomodule: what to use for what, what blocks the event loop, and the five lines of code that separate "secure" from "breached on Hacker News".Read the full lesson -> [link]
#NodeJS #Security #Cryptography #BackendDevelopment #InterviewPrep
What You'll Learn
- How
crypto.createHashworks and why SHA-256 is the WRONG choice for passwords - Why bcrypt and argon2 are slow on purpose and how the cost factor protects you
- HMAC for verifying API signatures and webhook payloads (Stripe, GitHub, Slack)
- Symmetric encryption with AES-256-GCM — key, IV, and the critical authTag
- Generating cryptographically secure tokens with
randomBytesandrandomUUID - Why
===is a vulnerability andtimingSafeEqualis the fix - JWT signing basics — HS256 vs RS256 (full coverage in Chapter 8)
- PBKDF2 as a portable alternative to bcrypt
- Which crypto operations block Node's libuv thread pool
The Safe, the Envelope, and the Fingerprint — A Mental Model
Cryptography looks intimidating because the words sound interchangeable. "Hash", "encrypt", "sign", "encode" — they all feel like the same thing. They are not. Here is a mental model that will save you in interviews and in production.
Imagine you run a small office with three security needs.
The fingerprint (hash). You want to prove that a contract has not been altered. You take the document, run it through a machine, and out comes a unique fingerprint. Anyone who has the document can recompute the fingerprint and check it matches. You cannot reconstruct the document from the fingerprint — it is one-way. That is what createHash does. Perfect for file integrity checks. Disastrous for passwords, because a fast machine can guess billions of fingerprints per second until it finds one that matches.
The wax seal (HMAC and signatures). You want to prove that you sent a message. You combine the message with a secret only you know, run it through the fingerprint machine, and stamp the result on the envelope. Anyone with the secret can verify the seal. Anyone without it cannot forge one. That is HMAC — and it is how Stripe, GitHub, and every webhook provider proves their requests are real.
The locked safe (symmetric encryption). You want to send a document so that only the intended recipient can read it. You lock it in a safe with a key and ship it. The recipient unlocks it with the same key. That is AES-256-GCM. The "GCM" part adds a tamper-evident seal — if anyone touches the safe in transit, the unlock fails loudly.
+---------------------------------------------------------------+
| THE THREE PRIMITIVES |
+---------------------------------------------------------------+
| |
| HASH (fingerprint) |
| in: "hello world" |
| out: b94d27b9934d3e08... (one-way, deterministic) |
| use: file integrity, content addressing |
| NOT: password storage |
| |
| HMAC (wax seal) |
| in: message + secret |
| out: 9f86d081884c7d65... (one-way, keyed) |
| use: webhook verification, API request signing |
| |
| ENCRYPT (locked safe) |
| in: plaintext + key + iv |
| out: ciphertext + authTag (two-way) |
| use: storing secrets, encrypting payloads |
| |
| KDF (slow fingerprint for passwords) |
| in: password + salt + cost |
| out: hash (one-way, intentionally slow) |
| use: bcrypt, argon2, scrypt, pbkdf2 — PASSWORDS ONLY |
| |
+---------------------------------------------------------------+
If you remember nothing else from this lesson, remember which tool maps to which job. Half of all production security bugs come from picking the wrong primitive.
createHash — Fingerprints for Files, Not Passwords
The crypto.createHash function gives you SHA-256, SHA-512, SHA-1, MD5, and friends. It is the right tool for content addressing, ETags, integrity checks, and deduplication. It is the wrong tool for passwords. We will see both — the right use and the wrong one — so you can explain the difference in an interview.
Example 1 — SHA-256 hashing for file integrity
// example-01-sha256-hash.js
// SHA-256 is correct for file integrity, content addressing,
// ETags, and deduplication. It is NOT for passwords.
const crypto = require('node:crypto');
const fs = require('node:fs');
// Hash a small string in one shot
function hashString(input) {
// createHash returns a Hash object that you feed and then digest
return crypto.createHash('sha256').update(input).digest('hex');
}
console.log(hashString('hello world'));
// -> b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
// Hash a large file by streaming chunks (does NOT load it into memory)
function hashFileStream(path) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(path);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
// Use case: dedupe uploads, generate ETags, verify downloads
hashFileStream(__filename).then((digest) => {
console.log('this file fingerprint:', digest);
});
Why SHA-256 is WRONG for passwords
SHA-256 was engineered to be fast. A modern GPU computes roughly 10 billion SHA-256 hashes per second. If your users table leaks and you stored sha256(password), an attacker can try every word in the English dictionary in under a millisecond and every 8-character lowercase password in about an hour. Salts help against rainbow tables but do nothing against raw speed.
What you actually need is a slow hash — one that takes 100-300 milliseconds per guess on purpose. That is what bcrypt, argon2, scrypt, and PBKDF2 are for. They have a tunable cost factor: bump it up every few years as hardware gets faster, and the attacker pays the price.
bcrypt and argon2 — Slow on Purpose
bcrypt and argon2 are not in Node core — install them from npm. They are the industry-standard password hashes. argon2 is the newer winner of the Password Hashing Competition; bcrypt is the battle-tested veteran. Either is fine. Never roll your own.
Example 2 — Hashing and verifying passwords with bcrypt
// example-02-bcrypt-password.js
// npm install bcrypt
const bcrypt = require('bcrypt');
// The cost factor (also called "rounds" or "work factor")
// 12 means 2^12 = 4096 internal iterations.
// Higher = slower = more secure. Re-tune every 2-3 years.
// Aim for ~250ms per hash on your production hardware.
const COST = 12;
async function signup(plainPassword) {
// bcrypt generates a random salt internally and embeds it in the output
// The returned string includes algorithm, cost, salt, and hash
const hash = await bcrypt.hash(plainPassword, COST);
console.log(hash);
// -> $2b$12$KIXQv... <-- store THIS in the database
return hash;
}
async function login(plainPassword, storedHash) {
// bcrypt.compare extracts the salt from storedHash and re-hashes plainPassword
// It uses constant-time comparison internally — safe against timing attacks
const ok = await bcrypt.compare(plainPassword, storedHash);
return ok;
}
(async () => {
const hash = await signup('correct horse battery staple');
console.log('login good password:', await login('correct horse battery staple', hash));
console.log('login bad password:', await login('wrong guess', hash));
})();
What about argon2?
// npm install argon2
const argon2 = require('argon2');
const hash = await argon2.hash('my password', {
type: argon2.argon2id, // argon2id is the recommended variant
memoryCost: 19456, // 19 MB — costs attackers GPU memory
timeCost: 2, // iterations
parallelism: 1,
});
const ok = await argon2.verify(hash, 'my password');
argon2 has the advantage of being memory-hard — it forces the attacker to allocate large amounts of RAM per guess, which neutralizes GPU and ASIC attacks much better than bcrypt. If you are starting a new project in 2026, argon2id is the safer default.
PBKDF2 — the portable, no-dependency option
If you cannot install native modules (locked-down environments, edge runtimes, serverless cold-start sensitivity), Node ships PBKDF2 in core:
const crypto = require('node:crypto');
function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16);
// 600,000 iterations is the OWASP 2023 recommendation for SHA-256
crypto.pbkdf2(password, salt, 600_000, 32, 'sha256', (err, derived) => {
if (err) return reject(err);
// Store salt + hash together so you can verify later
resolve(salt.toString('hex') + ':' + derived.toString('hex'));
});
});
}
PBKDF2 runs on the libuv thread pool, so it does not block the event loop. It is weaker than bcrypt and much weaker than argon2 against GPU attacks, but it is built-in and acceptable when better options are unavailable.
HMAC — Proving Who Sent the Message
HMAC (Hash-based Message Authentication Code) takes a message and a secret and produces a tag. Anyone with the secret can verify the tag. Anyone without it cannot forge one. This is how every major webhook provider — Stripe, GitHub, Slack, Shopify — proves to your server that the incoming POST really came from them.
Example 3 — Verifying a webhook signature
// example-03-hmac-webhook.js
// Real-world: this is exactly how GitHub webhook verification works
const crypto = require('node:crypto');
// The shared secret you configured in the webhook provider's dashboard
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super-secret-shared-key';
// The provider sends this header alongside every webhook
// Format used by GitHub: "sha256=<hex-digest>"
function signPayload(rawBody, secret) {
return 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
}
function verifyWebhook(rawBody, headerSignature, secret) {
// Recompute what the signature SHOULD be
const expected = signPayload(rawBody, secret);
// CRITICAL: use timingSafeEqual, not ===
// Plain string comparison leaks information through timing differences
const a = Buffer.from(expected);
const b = Buffer.from(headerSignature);
// Buffers must be equal length or timingSafeEqual throws
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Simulate an incoming webhook
const body = JSON.stringify({ event: 'payment.succeeded', amount: 4200 });
const signature = signPayload(body, WEBHOOK_SECRET);
console.log('valid signature: ', verifyWebhook(body, signature, WEBHOOK_SECRET));
console.log('forged signature: ', verifyWebhook(body, 'sha256=deadbeef', WEBHOOK_SECRET));
Two things to notice. First, you must hash the raw request body bytes, not the parsed JSON object. Re-serializing JSON can change spacing or key order and break the signature. Use a body parser that preserves the raw buffer (Express has express.raw(), Fastify exposes request.rawBody). Second, you must compare with timingSafeEqual, never ===. We will see why next.
AES-256-GCM — The Locked Safe
When you need to store a secret encrypted at rest — an OAuth refresh token, a credit card number for retry, a private file — you want symmetric encryption. AES-256-GCM is the modern standard. The "GCM" suffix means Galois/Counter Mode, which gives you authenticated encryption: tampering is detected on decrypt.
Three things go in: a key (32 bytes), an IV (12 bytes, never reused with the same key), and the plaintext. Three things come out: the ciphertext, the IV, and the authTag (16 bytes). All three are needed to decrypt.
Example 4 — Encrypting and decrypting with AES-256-GCM
// example-04-aes-gcm.js
const crypto = require('node:crypto');
// Generate a 256-bit key ONCE and store it in your secrets manager
// In production: load this from env / KMS / Vault — never hardcode
const KEY = crypto.randomBytes(32); // 32 bytes = 256 bits
function encrypt(plaintext) {
// GCM requires a 12-byte IV. Must be UNIQUE per encryption with the same key.
// Random IVs are safe up to ~2^32 messages per key.
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
// The authTag is generated by .final() and must be retrieved after
const authTag = cipher.getAuthTag();
// Return all three concatenated as a single base64 blob
// Layout: [iv (12)] [authTag (16)] [ciphertext (n)]
return Buffer.concat([iv, authTag, ciphertext]).toString('base64');
}
function decrypt(blob) {
const data = Buffer.from(blob, 'base64');
const iv = data.subarray(0, 12);
const authTag = data.subarray(12, 28);
const ciphertext = data.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
// setAuthTag must be called BEFORE .final() — it tells decipher
// what the expected tag is so it can verify integrity
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(), // throws if authTag does not verify (tampering)
]);
return plaintext.toString('utf8');
}
const secret = 'user_oauth_refresh_token_xyz_123';
const encrypted = encrypt(secret);
console.log('encrypted:', encrypted);
console.log('decrypted:', decrypt(encrypted));
// Tamper with the ciphertext — decrypt will THROW
try {
const tampered = encrypted.slice(0, -2) + 'AA';
decrypt(tampered);
} catch (err) {
console.log('tamper detected:', err.message);
}
Three rules you must never break with GCM:
- Never reuse an IV with the same key. Reusing an IV completely breaks GCM — an attacker can recover the plaintext of both messages and forge new ones. Always use
randomBytes(12). - Always store the authTag. Without it, decryption will fail. Without verifying it, you have no integrity protection.
- Never share keys across environments. Dev, staging, and prod each get their own key from a secrets manager.
randomBytes, randomUUID, and timingSafeEqual
The last group of crypto features handles the small but critical operations: generating tokens and comparing them safely.
Example 5 — Tokens and constant-time comparison
// example-05-tokens-and-compare.js
const crypto = require('node:crypto');
// 1. Generate a cryptographically secure random token
// Use this for: API keys, password reset links, email confirmation tokens,
// session IDs, CSRF tokens, anything that must be unguessable.
function generateToken(bytes = 32) {
// 32 bytes = 256 bits of entropy = uncrackable
// hex encoding gives 64 chars; base64url gives 43 chars (URL safe)
return crypto.randomBytes(bytes).toString('base64url');
}
console.log('api token:', generateToken());
// -> rT9_xK2nQp... (43 chars, URL safe)
// 2. Generate a UUID v4 (random) — built into Node 14.17+
// Use this for: database row IDs, idempotency keys, request IDs
const id = crypto.randomUUID();
console.log('uuid:', id);
// -> "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// 3. Constant-time comparison — REQUIRED for token verification
function safeCompare(received, expected) {
const a = Buffer.from(received);
const b = Buffer.from(expected);
// timingSafeEqual REQUIRES equal-length buffers and throws otherwise
// The length check below must be done first — but make it constant time too
if (a.length !== b.length) {
// Compare against itself so total time is roughly constant either way
crypto.timingSafeEqual(a, a);
return false;
}
return crypto.timingSafeEqual(a, b);
}
const apiKey = generateToken();
console.log('right key:', safeCompare(apiKey, apiKey));
console.log('wrong key:', safeCompare(generateToken(), apiKey));
Why === is a vulnerability
JavaScript's === operator returns false as soon as it finds the first non-matching character. That means comparing "abc..." against "abd..." returns faster than comparing "abc..." against "abx...". The difference is nanoseconds, but over millions of network requests an attacker can statistically recover your token one character at a time. This is called a timing attack, and it has been used in real exploits (Keyczar, Meteor, several Bitcoin wallets).
crypto.timingSafeEqual always reads both buffers fully and XORs every byte, so the comparison takes the same amount of time whether the inputs match in the first byte or the last. Use it for every secret comparison: API keys, HMAC signatures, session tokens, anything an attacker controls one side of.
JWT Signing — HS256 vs RS256 (Pointer to Chapter 8)
JSON Web Tokens are how most modern APIs authenticate requests. The crypto primitives you just learned are exactly what powers them under the hood.
- HS256 uses HMAC-SHA-256. The same secret signs and verifies. Fast, simple, but every service that needs to verify must hold the secret — which means a leak in any service compromises all of them. Good for monoliths.
- RS256 uses RSA. A private key signs; a matching public key verifies. The auth server holds the private key, and every other service holds only the public key. A leak of any verifier service is harmless. Required for OAuth, OpenID Connect, and any federated identity setup.
Chapter 8 has full coverage with code, key generation, rotation, and the common pitfalls (the infamous "alg: none" attack, key confusion, missing audience checks). For now, just know that JWT is built on HMAC and RSA, both of which live in crypto.
Which Crypto Operations Block the Event Loop?
This is a frequent interview question and a real production pitfall. Node's event loop is single-threaded. Crypto operations vary in cost and in where they run.
+---------------------------------------------------------------+
| CRYPTO + THE EVENT LOOP |
+---------------------------------------------------------------+
| |
| RUNS ON MAIN THREAD (blocks event loop): |
| createHash().update().digest() <- sync API |
| createHmac().update().digest() <- sync API |
| createCipheriv / createDecipheriv <- sync API |
| timingSafeEqual <- sync, microseconds |
| randomUUID <- sync, microseconds |
| |
| RUNS ON LIBUV THREAD POOL (does NOT block event loop): |
| crypto.pbkdf2(...) <- async callback |
| crypto.scrypt(...) <- async callback |
| crypto.randomBytes(size, cb) <- async if cb given |
| crypto.generateKeyPair(...) <- async callback |
| bcrypt.hash / bcrypt.compare <- native, async |
| argon2.hash / argon2.verify <- native, async |
| |
| WHY IT MATTERS: |
| Default thread pool size is 4. If you fire 5 bcrypt hashes |
| at once, the 5th waits for one to finish. For high-traffic |
| login endpoints, bump UV_THREADPOOL_SIZE. |
| |
+---------------------------------------------------------------+
The practical rule: hashing a small string with createHash is microseconds and fine on the main thread. Hashing a 2 GB file with createHash synchronously will freeze your server — use a stream. Password hashing must use the async API of bcrypt/argon2/pbkdf2 so it lands on the thread pool. And under load, increase UV_THREADPOOL_SIZE (default 4) to match your expected concurrent hash operations.
Common Mistakes
1. Using SHA-256 (or MD5, or SHA-1) to store passwords. SHA was built to be fast. A modern GPU computes ~10 billion SHA-256 hashes per second, so a leaked database is cracked in hours. Always use a slow KDF: bcrypt (cost 12+), argon2id, scrypt, or PBKDF2 with at least 600,000 iterations. The slowness is the security feature — never "optimize" it away.
2. Comparing tokens, signatures, or hashes with ===.
String equality in JavaScript short-circuits on the first mismatched character, leaking timing information that an attacker can exploit over many requests to recover the secret one byte at a time. Always use crypto.timingSafeEqual on equal-length Buffers for any comparison where one side is attacker-controlled.
3. Reusing the IV with AES-GCM.
If you encrypt two different messages with the same key + IV, GCM is catastrophically broken — an attacker can XOR the ciphertexts to recover both plaintexts and forge new messages. Always generate a fresh IV with crypto.randomBytes(12) per encryption, and store it alongside the ciphertext.
4. Forgetting the authTag with AES-GCM, or storing it but not verifying it.
GCM's authentication is what makes it tamper-evident. If you do not call getAuthTag() after encrypting, you cannot decrypt later. If you do not call setAuthTag() before decrypting, you have stripped the integrity check and an attacker can flip bits in your ciphertext undetected.
5. Hashing the parsed JSON body for webhook verification instead of the raw bytes.
JSON.stringify(req.body) does not produce byte-for-byte the same output the sender produced — key order, whitespace, and number formatting can differ. Webhook signatures are computed over raw request bytes. Configure your framework to expose the raw body (Express: express.raw({ type: '*/*' }), Fastify: request.rawBody) and HMAC that.
Interview Questions
1. "Why is crypto.createHash('sha256') the wrong way to store passwords, and what should you use instead?"
SHA-256 is a general-purpose cryptographic hash designed to be extremely fast — modern GPUs compute roughly 10 billion SHA-256 hashes per second. That speed makes it perfect for file integrity, content addressing, and ETags, but disastrous for password storage. If your database leaks, an attacker brute-forces every weak password in hours. The correct tools are deliberately slow, tunable, password-specific KDFs: bcrypt, argon2id, scrypt, or PBKDF2 with at least 600,000 iterations. They have a cost factor you can raise as hardware improves, and they incorporate a per-password salt automatically. argon2id is the modern recommendation because it is also memory-hard, neutralizing GPU and ASIC attacks more thoroughly than bcrypt.
2. "Explain the difference between hashing, HMAC, and symmetric encryption. When do you use each?"
A hash (createHash) is a one-way fingerprint of data. Anyone can compute it, you cannot reverse it. Use it for file integrity, content addressing, ETags, and dedupe. An HMAC (createHmac) is a hash combined with a secret key — only someone holding the key can produce a valid tag, and anyone with the key can verify it. Use it for webhook verification, API request signing, and proving message authenticity. Symmetric encryption (AES-256-GCM via createCipheriv) is a two-way operation that turns plaintext into ciphertext using a key, and you can recover the plaintext with the same key. Use it when you need confidentiality — encrypting OAuth tokens at rest, protecting payloads in transit when TLS is not enough, encrypting backups. Hashing proves integrity, HMAC proves authenticity, encryption provides confidentiality.
3. "What is timingSafeEqual and why can't you just use ===?"
timingSafeEqual compares two equal-length Buffers in constant time — the comparison takes the same number of CPU cycles whether the inputs match in the first byte or the last. JavaScript's === short-circuits as soon as it finds a mismatch, so comparing against "abc..." is faster when the input starts with "abd" than when it starts with "abx". Over millions of requests, an attacker can statistically measure those nanosecond differences and recover your secret one character at a time. This is called a timing attack and has been exploited in real systems including Keyczar and Meteor. Always use crypto.timingSafeEqual for HMAC signatures, API keys, session tokens, and any comparison where one side is attacker-controlled. Note that timingSafeEqual requires the buffers to be the same length and throws if they are not, so handle the length check carefully.
4. "Walk me through encrypting a string with AES-256-GCM. What are the key, IV, and authTag?"
AES-256-GCM is authenticated symmetric encryption. You need a key of 32 bytes (256 bits), generated once with crypto.randomBytes(32) and stored in a secrets manager. For each encryption, you generate a fresh 12-byte IV (initialization vector) with crypto.randomBytes(12) — the IV must never repeat with the same key, or GCM is completely broken. You then call crypto.createCipheriv('aes-256-gcm', key, iv), push your plaintext through update and final, and retrieve the authTag (16 bytes) with cipher.getAuthTag(). The authTag is the integrity check — it proves the ciphertext has not been tampered with. To decrypt, you call createDecipheriv with the same key and IV, call setAuthTag() with the stored tag before final(), then update/final. If anything has been altered — even a single bit of ciphertext — final() throws. You must store all three together: IV, authTag, and ciphertext.
5. "Which crypto operations block the event loop and which ones run on the thread pool?"
The synchronous chained API — createHash().update().digest(), createHmac().update().digest(), createCipheriv operations, timingSafeEqual, and randomUUID — runs on the main thread and blocks the event loop. For small inputs that is microseconds and harmless, but hashing or encrypting large buffers synchronously will freeze your server. The async APIs — crypto.pbkdf2, crypto.scrypt, crypto.randomBytes(size, callback), crypto.generateKeyPair, and the native modules bcrypt and argon2 — run on the libuv thread pool and do not block. The thread pool defaults to 4 workers, so if you fire 5 concurrent bcrypt hashes the 5th waits in queue. For login-heavy services, set UV_THREADPOOL_SIZE to match expected concurrency (often 8-16). Streaming hashes for large files is also fine because update chunks return quickly between event loop ticks.
Quick Reference — crypto Cheat Sheet
+---------------------------------------------------------------+
| CRYPTO PRIMITIVES CHEAT SHEET |
+---------------------------------------------------------------+
| |
| HASH (file integrity, ETags): |
| crypto.createHash('sha256') |
| .update(data).digest('hex') |
| |
| PASSWORD HASH (use a KDF, NOT a plain hash): |
| bcrypt.hash(pw, 12) -> bcrypt 2^12 rounds |
| argon2.hash(pw, { type: id }) -> recommended in 2026 |
| crypto.pbkdf2(pw, salt, 600000, 32, 'sha256', cb) |
| |
| HMAC (webhook verification, API signing): |
| crypto.createHmac('sha256', secret) |
| .update(rawBody).digest('hex') |
| |
| ENCRYPT (AES-256-GCM, authenticated): |
| const iv = crypto.randomBytes(12); |
| const c = crypto.createCipheriv('aes-256-gcm', key, iv); |
| const ct = Buffer.concat([c.update(pt), c.final()]); |
| const tag = c.getAuthTag(); |
| |
| DECRYPT (must setAuthTag BEFORE final): |
| const d = crypto.createDecipheriv('aes-256-gcm', key, iv); |
| d.setAuthTag(tag); |
| const pt = Buffer.concat([d.update(ct), d.final()]); |
| |
| RANDOM TOKENS: |
| crypto.randomBytes(32).toString('base64url') |
| crypto.randomUUID() |
| |
| CONSTANT-TIME COMPARE (always for secrets): |
| crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| THE GOLDEN RULES |
+---------------------------------------------------------------+
| |
| 1. Passwords -> bcrypt / argon2id (NEVER plain SHA) |
| 2. File integrity -> SHA-256 createHash |
| 3. Webhooks -> HMAC over RAW request bytes |
| 4. Symmetric encrypt -> AES-256-GCM with fresh IV per message |
| 5. Tokens / API keys -> randomBytes(32).toString('base64url') |
| 6. UUIDs -> crypto.randomUUID() |
| 7. Compare secrets -> timingSafeEqual, NEVER === |
| 8. Async for KDFs -> pbkdf2/scrypt/bcrypt do NOT block |
| 9. JWT HS256 -> HMAC under the hood (Chapter 8) |
| 10. JWT RS256 -> RSA sign/verify (Chapter 8) |
| |
+---------------------------------------------------------------+
| Need | Wrong Choice | Right Choice |
|---|---|---|
| Store a password | sha256(pw) | bcrypt.hash(pw, 12) or argon2.hash |
| Verify webhook | body === expected | timingSafeEqual over raw body HMAC |
| Encrypt secret at rest | aes-256-cbc (no auth) | aes-256-gcm with authTag |
| Generate API key | Math.random() | crypto.randomBytes(32) |
| Generate ID | Date.now() + Math.random() | crypto.randomUUID() |
| Compare tokens | a === b | crypto.timingSafeEqual(a, b) |
| Hash big file | fs.readFileSync then hash | createReadStream().pipe(hash) |
| Password hash in serverless | bcrypt native binary | crypto.pbkdf2 (built-in) |
Prev: Lesson 3.4 -- HTTP Server From Scratch Next: Lesson 4.1 -- Buffers
This is Lesson 3.5 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.