Node.js crypto module — practical patterns for real apps
← Back
April 4, 2026Node.js8 min read

Node.js crypto module — practical patterns for real apps

Published April 4, 20268 min read

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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

Share this
← All Posts8 min read