Node.js crypto module — practical patterns for real apps
The Node.js crypto module is powerful and easy to misuse. MD5 for password hashing (never), ECB mode for AES (never), predictable tokens from Math.random (never). Here are the patterns that are actually secure — hash verification, authenticated encryption, secure token generation, and password hashing — with code you can use directly.
Secure random tokens
Use these for session IDs, password reset tokens, API keys — anywhere you need an unguessable random value:
import { randomBytes, randomUUID } from 'node:crypto';
// 32 bytes = 256 bits of entropy, URL-safe base64
function generateToken(bytes = 32): string {
return randomBytes(bytes).toString('base64url');
}
// UUID v4 (122 bits of entropy) — for IDs, not secrets
function generateId(): string {
return randomUUID();
}
// Numeric PIN — for SMS codes etc.
function generatePin(digits = 6): string {
const max = 10 ** digits;
const buffer = randomBytes(4);
const value = buffer.readUInt32BE(0) % max;
return value.toString().padStart(digits, '0');
}
// Usage
const sessionToken = generateToken(); // "xK8mF2vN..."
const userId = generateId(); // "550e8400-e29b-41d4-a716-..."
const smsCode = generatePin(6); // "047291"
HMAC signatures
HMAC is how you verify that a message was produced by someone who knows the secret key — used for webhook verification, signed URLs, API request signing:
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET!;
function signPayload(payload: string): string {
return createHmac('sha256', SECRET)
.update(payload)
.digest('hex');
}
// CRITICAL: use timingSafeEqual to prevent timing attacks
function verifySignature(payload: string, signature: string): boolean {
const expected = signPayload(payload);
const expectedBuffer = Buffer.from(expected, 'hex');
const receivedBuffer = Buffer.from(signature, 'hex');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, receivedBuffer);
}
// Verify a Stripe/GitHub-style webhook
function verifyWebhook(
body: string,
receivedSignature: string,
): boolean {
const expectedSig = `sha256=${signPayload(body)}`;
return verifySignature(expectedSig, receivedSignature);
}
Never use === to compare signatures. A timing attack can determine whether the signature is correct character by character by measuring response time differences. timingSafeEqual takes constant time regardless of where the strings differ.
AES-GCM encryption
AES-GCM is authenticated encryption — it encrypts and provides integrity verification. If anyone tampers with the ciphertext, decryption fails:
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
// Key must be exactly 32 bytes for AES-256
function generateKey(): Buffer {
return randomBytes(32);
}
function encrypt(plaintext: string, key: Buffer): {
ciphertext: string;
iv: string;
authTag: string;
} {
const iv = randomBytes(12); // 96-bit IV for GCM
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
return {
ciphertext: encrypted,
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
};
}
function decrypt(
ciphertext: string,
iv: string,
authTag: string,
key: Buffer,
): string {
const decipher = createDecipheriv(
ALGORITHM,
key,
Buffer.from(iv, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8'); // throws if authTag is wrong
return decrypted;
}
// Usage — store sensitive data (SSN, card number, etc.)
const key = generateKey();
const { ciphertext, iv, authTag } = encrypt('4111-1111-1111-1111', key);
// Store ciphertext, iv, authTag together
const original = decrypt(ciphertext, iv, authTag, key);
Password hashing with scrypt
For passwords, use scrypt (or bcrypt/argon2 via npm). Never use SHA-256 directly for passwords — it is too fast, making brute force attacks feasible:
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derivedKey = await scryptAsync(password, salt, 64) as Buffer;
// Store salt + hash together
return `${salt.toString('hex')}:${derivedKey.toString('hex')}`;
}
async function verifyPassword(
password: string,
storedHash: string,
): Promise<boolean> {
const [saltHex, hashHex] = storedHash.split(':');
const salt = Buffer.from(saltHex, 'hex');
const storedBuffer = Buffer.from(hashHex, 'hex');
const derivedKey = await scryptAsync(password, salt, 64) as Buffer;
return timingSafeEqual(storedBuffer, derivedKey);
}
// Usage
const hash = await hashPassword('user_password_123');
await verifyPassword('user_password_123', hash); // true
await verifyPassword('wrong_password', hash); // false
Key derivation from a master key
When you have one master secret but need multiple keys for different purposes, use HKDF to derive them:
import { hkdfSync } from 'node:crypto';
const MASTER_KEY = Buffer.from(process.env.MASTER_SECRET!, 'hex');
function deriveKey(purpose: string, length = 32): Buffer {
return Buffer.from(
hkdfSync('sha256', MASTER_KEY, '', purpose, length)
);
}
// Derive separate keys for different purposes from one master key
const encryptionKey = deriveKey('user-data-encryption');
const signingKey = deriveKey('webhook-signature');
const sessionKey = deriveKey('session-token');
What NOT to use
- MD5/SHA1: Broken for security purposes. Use SHA-256 or SHA-3 for hashing non-passwords.
- AES-CBC without authentication: Vulnerable to padding oracle attacks. Always use GCM or CCM.
- Math.random() for anything security-related: Not cryptographically random. Always use
randomBytes. - SHA-256 for passwords: Too fast. Use scrypt, bcrypt, or argon2.
- ECB mode: Does not randomise ciphertext blocks. Never use.
The Node.js crypto module has everything you need for production-grade cryptography. The patterns above cover 95% of real-world use cases without reaching for a third-party library.