Node.js ships a built-in crypto module that gives you production-grade cryptography without installing a single npm package. In 30 minutes you will have working code for random-byte generation, key derivation with PBKDF2 and scrypt, ECDH key exchange, Ed25519 digital signatures, and the newer WebCrypto API. Every example runs on Node.js 22 LTS and above, the active long-term support line as of June 2026.
The March 2026 Node.js security release fixed two high-severity TLS bugs, including CVE-2026-21637, which allowed remote attackers to crash TLS servers by exploiting unhandled exceptions in SNI callbacks. Understanding the crypto module is the first step toward writing server code that does not expose those surfaces in the first place. The 25.x release line had 2 high, 5 medium, and 2 low severity issues; the 22.x and 20.x lines each carried 2 high and 4 medium severity issues.
Prerequisites
- Node.js 22.x LTS (minimum 20.x); verify with
node --version - npm 10+; verify with
npm --version - Basic JavaScript and async/await familiarity
- A terminal on macOS, Linux, or Windows with WSL
- No external packages required. The
node:cryptomodule is built into every Node.js installation
Node.js 22 reached LTS status in October 2024 and receives security patches through April 2027. Node.js 20 receives patches through April 2026. If you are running Node.js 18 or older, upgrade now. The March 2026 security release patched versions v20.20.2, v22.22.2, v24.14.1, and v25.8.2 and does not backport to end-of-life lines.
What the Node.js Crypto Module Covers
The node:crypto module wraps OpenSSL through the V8 engine binding layer. It exposes three distinct APIs: the legacy callback/sync API available since Node.js 0.x, the KeyObject API introduced in Node.js 12 for type-safe key handling, and the standard Web Cryptography API (globalThis.crypto.subtle) available since Node.js 19. All three live in the same runtime but serve different use cases. This tutorial covers all three, so you can choose the right tool for each job.
| Feature | Legacy API | KeyObject API | WebCrypto API |
|---|---|---|---|
| Random bytes | crypto.randomBytes() | N/A | getRandomValues() |
| Hashing | createHash() | N/A | subtle.digest() |
| HMAC | createHmac() | HMAC key via createSecretKey() | subtle.sign('HMAC') |
| Key derivation | pbkdf2(), scrypt(), hkdfSync() | DeriveKey | subtle.deriveKey() |
| Asymmetric keys | generateKeyPair() | KeyObject | subtle.generateKey() |
| Signatures | createSign() / createVerify() | sign() / verify() | subtle.sign() / verify() |
| Browser portable | No | No | Yes |
Step 1: Project Setup
Create a dedicated directory and initialize a project. Using the node: prefix on the import forces Node.js to load the built-in module, bypassing any npm package named crypto that an attacker could inject via a compromised dependency chain. This one habit blocks the dependency-confusion attack class entirely for cryptographic code.
mkdir node-crypto-demo && cd node-crypto-demo
npm init -y
# Set project to use ES modules
node -e "const p=require('./package.json'); p.type='module'; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2))"
# Verify Node.js version
node --version # v22.x.x or higher expected
// index.mjs -- always use the node: prefix
import crypto from 'node:crypto';
// Sanity checks
console.log(crypto.getCiphers().includes('aes-256-gcm')); // true
console.log(crypto.getHashes().includes('sha256')); // true
console.log(crypto.getCurves().includes('prime256v1')); // true
console.log(typeof globalThis.crypto.subtle); // "object" (WebCrypto API)
Run node index.mjs and confirm all four checks print as expected. If getCiphers() throws, you are likely running an older Node.js build compiled without OpenSSL support. Install the official LTS binary from nodejs.org or use a version manager like nvm.
Step 2: Generating Cryptographically Secure Random Bytes
The single most common security mistake in Node.js applications is reaching for Math.random() when generating tokens, session IDs, or nonces. Math.random() is a pseudorandom number generator seeded at startup. An attacker who observes a few outputs can reconstruct the seed and predict all future values. Use crypto.randomBytes() instead: it pulls entropy directly from the operating system’s CSPRNG (/dev/urandom on Linux, CryptGenRandom on Windows).
import crypto from 'node:crypto';
// 32 bytes = 256 bits of entropy, the standard for session tokens and API keys
const token = crypto.randomBytes(32).toString('hex');
console.log('Hex token (64 chars):', token);
// URL-safe base64 -- no padding, no + or / characters, safe for URL query params
const urlSafeToken = crypto.randomBytes(24).toString('base64url');
console.log('base64url token:', urlSafeToken);
// RFC 4122 UUID v4 -- built in since Node.js 14.17, no uuid package needed
const uuid = crypto.randomUUID();
console.log('UUID:', uuid);
// e.g., "550e8400-e29b-41d4-a716-446655440000"
// Uniform random integer in [min, max) -- replaces Math.random() for ranges
const pin = crypto.randomInt(100000, 999999);
console.log('6-digit PIN:', pin);
// WebCrypto style -- identical CSPRNG, different API surface
const array = new Uint8Array(16);
globalThis.crypto.getRandomValues(array);
console.log('WebCrypto bytes:', Buffer.from(array).toString('hex'));
Use 32 bytes (256 bits) for session tokens, password reset links, and API keys. Use 16 bytes (128 bits) for nonces and IVs where the specification mandates a fixed size, such as AES-GCM’s 12-byte nonce or AES-CBC’s 16-byte IV. For human-readable verification codes, crypto.randomInt(100000, 999999) returns a uniform random integer with no modulo bias.
Step 3: SHA-256 and SHA-3 Hashing
Hashing turns arbitrary input into a fixed-length digest. The createHash() API uses a streaming interface, which lets you hash large files or network streams without loading them into memory all at once. SHA-256 is the workhorse for data integrity, checksums, and HMAC construction. SHA-3-256 is the NIST FIPS 202 standardized successor with an entirely different internal structure (the Keccak sponge function) that provides independent security assurance.
import crypto from 'node:crypto';
import { createReadStream } from 'node:fs';
// One-shot hash, simplest form
const sha256Digest = crypto
.createHash('sha256')
.update('hello world')
.digest('hex');
console.log('SHA-256:', sha256Digest);
// b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576f5d2f3b18be64523 (incorrect; real value differs)
// Actual: b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576f5d2f3b18be64523
// SHA-3-256 (available in Node.js 22 with OpenSSL 3)
const sha3Digest = crypto
.createHash('sha3-256')
.update('hello world')
.digest('hex');
console.log('SHA-3-256:', sha3Digest);
// BLAKE2b-512 -- fastest unkeyed hash available in Node.js
const blake2 = crypto
.createHash('blake2b512')
.update('hello world')
.digest('hex');
console.log('BLAKE2b-512:', blake2);
// Stream hashing -- hash a file without loading it into RAM
async function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
// Hash the same data across multiple update() calls
const h = crypto.createHash('sha256');
h.update('part1');
h.update('part2');
const combined = h.digest('hex');
console.log('Incremental hash:', combined);
// Same as hashing 'part1part2' in one call
| Algorithm | Output Size | Approx Speed | Security Level | Status |
|---|---|---|---|---|
| SHA-256 | 32 bytes | 600 MB/s | 128-bit | Recommended |
| SHA-3-256 | 32 bytes | 200 MB/s | 128-bit | Recommended (FIPS 202) |
| SHA-512 | 64 bytes | 500 MB/s | 256-bit | Recommended |
| BLAKE2b-512 | 64 bytes | 1200 MB/s | 256-bit | Recommended (non-FIPS) |
| MD5 | 16 bytes | 2000 MB/s | Broken | Never use for security |
| SHA-1 | 20 bytes | 800 MB/s | Broken (2017) | Legacy only |
For a deep dive into how SHA-256 works internally, see SHA-256 Explained: How It Works and Why It Matters. For HMAC construction on top of hash functions, including keyed authentication, see HMAC-SHA256 in Node.js: 10 Steps, 20 Min [2026].
Step 4: PBKDF2 Key Derivation
PBKDF2 (Password-Based Key Derivation Function 2) derives a cryptographic key from a password by applying an HMAC repeatedly, making brute-force attacks expensive by requiring many iterations of CPU work. It is the right tool for deriving AES encryption keys from user passwords when you need to store an encrypted blob that the user can unlock with their password. For storing passwords and verifying them, use Argon2id or bcrypt instead, because those are purpose-built to resist GPU parallelism through memory hardness.
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const pbkdf2 = promisify(crypto.pbkdf2);
async function deriveKey(password, salt = null) {
// Generate a fresh 16-byte salt if none is provided
const s = salt ?? crypto.randomBytes(16);
// 600,000 iterations: OWASP 2023 minimum for SHA-256
const key = await pbkdf2(
password, // user password (string or Buffer)
s, // salt (Buffer)
600_000, // iterations -- increase every 2 years as CPUs get faster
32, // output key length in bytes (32 = 256-bit AES key)
'sha256' // underlying PRF
);
return { key, salt: s };
}
// First call: generate salt and derive key
const { key, salt } = await deriveKey('correct horse battery staple');
console.log('Key (hex):', key.toString('hex'));
console.log('Salt (hex):', salt.toString('hex'));
// Re-derive with stored salt, must produce identical key
const { key: key2 } = await deriveKey('correct horse battery staple', salt);
console.log('Keys match:', crypto.timingSafeEqual(key, key2)); // true
// Wrong password -- keys do NOT match
const { key: wrongKey } = await deriveKey('wrong password', salt);
console.log('Wrong password match:', crypto.timingSafeEqual(key, wrongKey)); // false
OWASP recommends 600,000 iterations of HMAC-SHA256 as of 2023, which produces roughly 80 milliseconds of wall-clock time on a 2024 server CPU. This is acceptable for interactive logins but too slow for API key verification on every request; derive a key once at session start and cache the result in memory. Store the salt alongside the derived key in plaintext; the salt is not secret, and you need it to re-derive the same key later.
Step 5: Scrypt Key Derivation
Scrypt improves on PBKDF2 by requiring large sequential memory access in addition to CPU time. A GPU that can run 50,000 PBKDF2 threads in parallel cannot achieve the same with scrypt because each thread needs to access 128 MB of RAM sequentially. This property, called memory hardness, makes scrypt attacks 100x more expensive per dollar of hardware than PBKDF2 attacks. Node.js 18+ includes a native scrypt implementation that runs on a dedicated libuv thread, so it does not block the event loop.
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(crypto.scrypt);
async function scryptDeriveKey(password, salt = null) {
const s = salt ?? crypto.randomBytes(16);
// N=131072 (2^17): requires 128 MB of RAM per attempt
// r=8: block size -- do not change without recalculating memory
// p=1: parallelization -- increase to slow down without extra memory
const key = await scrypt(password, s, 32, {
N: 131072, // CPU/memory cost factor (must be power of 2)
r: 8, // block size
p: 1, // parallelization factor
maxmem: 134217728 // 128 MB cap -- raise this if you increase N
});
return { key: Buffer.from(key), salt: s };
}
const { key, salt } = await scryptDeriveKey('my-secret-password');
console.log('Scrypt key:', key.toString('hex')); // 64 hex chars = 32 bytes
// Time the derivation
const t0 = performance.now();
await scryptDeriveKey('benchmark', crypto.randomBytes(16));
console.log(`Scrypt duration: ${(performance.now() - t0).toFixed(0)}ms`);
// Typically 80-200ms with N=131072 on modern server hardware
At N=131072 and r=8, scrypt requires exactly 128 MB of RAM per derivation attempt. An attacker with a 12 GB GPU can run roughly 94 parallel threads, far fewer than the millions possible with PBKDF2. Tune N upward as RAM gets cheaper, targeting 100 milliseconds derivation time on your slowest production server. Double N roughly every four years to maintain the same relative cost.
Step 6: HKDF Key Expansion
HKDF (HMAC-based Key Derivation Function, RFC 5869) takes cryptographically strong input key material (IKM), such as a Diffie-Hellman shared secret, and expands it into one or more keys of any desired length. Unlike PBKDF2 and scrypt, HKDF is not designed for passwords. It assumes the input already has high entropy. TLS 1.3, Signal Protocol, and WireGuard all use HKDF to expand session secrets into independent encryption and authentication keys.
import crypto from 'node:crypto';
function expandKey(inputKeyMaterial, info, outputLength = 32) {
const salt = crypto.randomBytes(32); // optional but recommended
// hkdfSync returns an ArrayBuffer
const derivedKey = crypto.hkdfSync(
'sha256', // hash algorithm
inputKeyMaterial, // high-entropy input (e.g., ECDH shared secret)
salt, // optional salt -- treat as public, store with ciphertext
Buffer.from(info), // application-specific context label
outputLength // output key length in bytes
);
return { derivedKey: Buffer.from(derivedKey), salt };
}
// Derive two independent keys from the same source material
const sharedSecret = crypto.randomBytes(32); // in practice, from ECDH exchange
const { derivedKey: encKey } = expandKey(sharedSecret, 'v1:encryption-key');
const { derivedKey: macKey } = expandKey(sharedSecret, 'v1:mac-key');
console.log('Enc key:', encKey.toString('hex'));
console.log('MAC key:', macKey.toString('hex'));
console.log('Keys are different:', !encKey.equals(macKey)); // true -- domain separated
The info parameter is a domain-separation string. Passing a different info value for each derived key guarantees that none of them share any cryptographic relationship with the others, even though they all came from the same source material. Never pass the same info value for two different purposes. Version it (e.g., 'v1:enc') so you can rotate the derivation logic without breaking existing encrypted data.
Step 7: ECDH Key Exchange
Elliptic Curve Diffie-Hellman (ECDH) lets two parties compute a shared secret over an insecure channel without ever transmitting the secret itself. Each party generates a public/private key pair, shares only the public key, and computes the shared secret locally using their private key and the other party’s public key. The result is the same on both sides. This is the foundation of every TLS handshake and of protocols like Signal and WireGuard.
import crypto from 'node:crypto';
// Alice generates her ECDH key pair on P-256
const alice = crypto.createECDH('prime256v1');
alice.generateKeys();
const alicePublic = alice.getPublicKey(); // Buffer -- share this
// Bob generates his ECDH key pair on P-256
const bob = crypto.createECDH('prime256v1');
bob.generateKeys();
const bobPublic = bob.getPublicKey(); // Buffer -- share this
// Both compute the shared secret independently using the other's public key
const aliceShared = alice.computeSecret(bobPublic);
const bobShared = bob.computeSecret(alicePublic);
console.log('Shared secrets match:', aliceShared.equals(bobShared)); // true
console.log('Shared secret length:', aliceShared.length, 'bytes'); // 32 bytes (P-256)
// Raw ECDH output is a field element -- always pass through HKDF before use
const aesKey = Buffer.from(
crypto.hkdfSync('sha256', aliceShared, crypto.randomBytes(32), Buffer.from('v1:aes-key'), 32)
);
console.log('Derived AES key:', aesKey.toString('hex'));
The raw ECDH output is not uniformly distributed over all possible byte sequences; it is a point on the elliptic curve, constrained by the curve’s mathematical structure. Always pass it through HKDF (Step 6) before using it as an encryption or MAC key. The P-256 curve provides 128-bit security. X25519 (Curve25519) is faster and avoids potential NIST curve concerns but uses a different Node.js API (crypto.diffieHellman() with KeyObject). See the Advanced Tips section for X25519 code.
Step 8: Ed25519 Digital Signatures
Ed25519 is the Edwards-curve Digital Signature Algorithm built on Curve25519. It produces 64-byte signatures, requires no randomness at signing time (deterministic), and verifies roughly 10 times faster than RSA-2048. SSH, WireGuard, TLS 1.3 certificate keys, and most modern protocols prefer Ed25519 over RSA and ECDSA. Node.js has supported Ed25519 natively since Node.js 12 and the API is stable and unchanged in Node.js 22.
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const generateKeyPair = promisify(crypto.generateKeyPair);
// Generate Ed25519 key pair
const { privateKey, publicKey } = await generateKeyPair('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
console.log('Public key:\n', publicKey);
// -----BEGIN PUBLIC KEY-----
// MCowBQYDK2VwAyEA...44 bytes total (32-byte key + header overhead)
// -----END PUBLIC KEY-----
// Sign a message -- pass null as hash because Ed25519 handles its own hashing
const message = Buffer.from('Transfer $500 to Alice, timestamp: 2026-06-17T12:00:00Z');
const signature = crypto.sign(null, message, privateKey);
console.log('Signature length:', signature.length, 'bytes'); // always 64 bytes
console.log('Signature (hex):', signature.toString('hex'));
// Verify the signature
const isValid = crypto.verify(null, message, publicKey, signature);
console.log('Valid:', isValid); // true
// Detect tampering
const tampered = Buffer.from('Transfer $5000 to Alice, timestamp: 2026-06-17T12:00:00Z');
const isTampered = crypto.verify(null, tampered, publicKey, signature);
console.log('Tampered passes:', isTampered); // false
Pass null as the first argument to crypto.sign() when using Ed25519. The curve applies SHA-512 internally, so you do not specify an external hash function. For ECDSA you must specify the hash: crypto.sign('sha256', data, ecdsaPrivateKey). Confusing these two produces the Error [ERR_OSSL_EVP_CTRL_NOT_SUPPORTED]: ctrl not supported error, which is one of the most common Ed25519 integration mistakes.
For the theory behind digital signatures, how they differ from HMAC, and why hashes are signed rather than the raw message, see Digital Signatures Explained: How They Work and Why Hashes Matter.
Step 9: ECDSA Signatures with P-256
ECDSA (Elliptic Curve Digital Signature Algorithm) on P-256 is the FIPS 186-5 approved alternative to Ed25519 when NIST curve compliance is required, such as in government systems or environments subject to PCI-DSS audit. ECDSA P-256 is also the scheme behind JWT ES256 tokens (see JWT authentication in Node.js) and TLS client certificates. The key size is 32 bytes, smaller than RSA-2048’s 256-byte key while providing equivalent security.
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const generateKeyPair = promisify(crypto.generateKeyPair);
// Generate P-256 ECDSA key pair
const { privateKey: ecPriv, publicKey: ecPub } = await generateKeyPair('ec', {
namedCurve: 'prime256v1', // P-256 (NIST / FIPS 186-5 approved)
privateKeyEncoding: { type: 'sec1', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
const payload = Buffer.from(JSON.stringify({
userId: 42,
action: 'delete-record',
ts: 1750161600
}));
// Sign with ECDSA using SHA-256 -- must specify hash algorithm (unlike Ed25519)
const ecSig = crypto.sign('sha256', payload, ecPriv);
console.log('ECDSA signature (DER encoded):', ecSig.toString('hex'));
console.log('Signature length:', ecSig.length, 'bytes'); // 70-72 bytes (DER variable length)
// Verify
const ecOk = crypto.verify('sha256', payload, ecPub, ecSig);
console.log('ECDSA valid:', ecOk); // true
// Export public key as JWK for browser consumption
const jwk = ecPub.export({ format: 'jwk' });
console.log('JWK public key:', JSON.stringify(jwk, null, 2));
// { kty: 'EC', crv: 'P-256', x: '...', y: '...' }
| Algorithm | Key Size | Signature Size | Sign Speed | Verify Speed | Deterministic |
|---|---|---|---|---|---|
| Ed25519 | 32 bytes | 64 bytes (fixed) | ~70,000/s | ~25,000/s | Yes |
| ECDSA P-256 | 32 bytes | 70-72 bytes (DER) | ~35,000/s | ~15,000/s | No (needs CSPRNG) |
| ECDSA P-384 | 48 bytes | 102-104 bytes | ~12,000/s | ~5,500/s | No |
| RSA-PSS 2048 | 256 bytes | 256 bytes (fixed) | ~2,000/s | ~60,000/s | No |
| RSA-PSS 4096 | 512 bytes | 512 bytes (fixed) | ~350/s | ~17,000/s | No |
For a comprehensive RSA implementation with OAEP encryption and PSS signing, see RSA Encryption in Node.js: 11 Steps [2026]. RSA verification is faster than signing, which makes it well-suited for read-heavy use cases (many verifiers, few signers), while Ed25519 is balanced and faster in both directions for moderate throughput.
Step 10: Timing-Safe Comparison
JavaScript’s === operator short-circuits on the first differing byte. That means comparing a valid HMAC against a user-supplied value takes marginally longer when the first byte matches than when it does not. An attacker measuring response latency across thousands of requests can reconstruct a valid token one byte at a time using a timing oracle attack. crypto.timingSafeEqual() fixes this by always comparing all bytes in constant time, regardless of where the first difference occurs.
import crypto from 'node:crypto';
// Generate a known-good HMAC tag
const secret = crypto.randomBytes(32);
const message = 'user-action:delete-account:1750161600';
const validHmac = crypto.createHmac('sha256', secret).update(message).digest();
// validHmac is a 32-byte Buffer
function verifyToken(userSupplied) {
let suppliedBuf;
try {
suppliedBuf = Buffer.from(userSupplied, 'hex');
} catch {
return false;
}
// Lengths MUST match -- if they differ, reject immediately
// Returning false early leaks the expected length, which is acceptable
// because a valid token always has a fixed, well-known length
if (suppliedBuf.length !== validHmac.length) {
return false;
}
// Constant-time comparison -- no timing oracle possible
return crypto.timingSafeEqual(suppliedBuf, validHmac);
}
// Test with correct token
console.log(verifyToken(validHmac.toString('hex'))); // true
// Test with tampered token (first byte flipped)
const tampered = Buffer.from(validHmac);
tampered[0] ^= 0xff;
console.log(verifyToken(tampered.toString('hex'))); // false
Use crypto.timingSafeEqual() anywhere you compare a user-controlled value against a secret: HMAC tags, session tokens, API keys, CSRF tokens, and password reset links. The function takes exactly two Buffer or TypedArray arguments of identical byte length and throws RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH] if they differ.
Step 11: The WebCrypto API in Node.js
Node.js 19 added globalThis.crypto as a globally available alias for the Web Cryptography API. Code written against the browser’s SubtleCrypto interface now runs unchanged in Node.js 22. This matters for isomorphic libraries that ship to both environments and for teams that want a single cryptography API across their entire stack.
// No import needed -- globalThis.crypto is always available in Node.js 19+
const { subtle } = globalThis.crypto;
// Generate an AES-256-GCM key
const aesKey = await subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable -- allows exportKey()
['encrypt', 'decrypt']
);
// Encrypt -- must generate a fresh IV every time
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); // 96-bit
const plaintext = new TextEncoder().encode('Hello, WebCrypto from Node.js!');
const ciphertext = await subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
plaintext
);
console.log('Ciphertext byte length:', ciphertext.byteLength);
// plaintext.length + 16 bytes (auth tag appended by GCM)
// Decrypt
const decrypted = await subtle.decrypt(
{ name: 'AES-GCM', iv },
aesKey,
ciphertext
);
console.log(new TextDecoder().decode(decrypted));
// "Hello, WebCrypto from Node.js!"
// Export key as JWK for safe storage or transmission
const jwk = await subtle.exportKey('jwk', aesKey);
console.log('JWK key:', JSON.stringify(jwk));
// { kty: 'oct', k: '...base64url...', alg: 'A256GCM', ... }
The WebCrypto API enforces key usage restrictions at generation time. A key created with ['encrypt', 'decrypt'] cannot be used for signing, and a signing key cannot encrypt. This compile-time safety prevents class of bugs where the wrong key type gets passed to the wrong operation, something the legacy node:crypto API allows silently. For the full reference, see the Node.js WebCrypto API documentation.
Step 12: Complete Working Project
The following self-contained module demonstrates a realistic “secure envelope” pattern: derive a key from a password using PBKDF2, encrypt a JSON payload with AES-256-GCM, sign the ciphertext with Ed25519, and verify plus decrypt on receipt. This pattern appears in API webhook signing, encrypted configuration storage, and secure inter-service messaging. Save it as secure-envelope.mjs and run with node secure-envelope.mjs.
// secure-envelope.mjs
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const pbkdf2 = promisify(crypto.pbkdf2);
const generateKeyPair = promisify(crypto.generateKeyPair);
// ── Key Generation ────────────────────────────────────────────────────────────
async function generateSigningKeys() {
return generateKeyPair('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
}
async function deriveEncryptionKey(password, salt) {
return pbkdf2(password, salt, 600_000, 32, 'sha256');
}
// ── Seal (Encrypt + Sign) ─────────────────────────────────────────────────────
async function seal(payload, password, signingPrivateKey) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12); // 96-bit nonce for AES-GCM
const key = await deriveEncryptionKey(password, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const plainBuf = Buffer.from(JSON.stringify(payload));
const ciphertext = Buffer.concat([cipher.update(plainBuf), cipher.final()]);
const authTag = cipher.getAuthTag(); // 16 bytes
// body = salt (16) || iv (12) || ciphertext (n) || authTag (16)
const body = Buffer.concat([salt, iv, ciphertext, authTag]);
// Sign the entire body with Ed25519 (no hash argument -- Ed25519 is self-hashing)
const signature = crypto.sign(null, body, signingPrivateKey);
return {
version: 1,
body: body.toString('base64'),
signature: signature.toString('base64')
};
}
// ── Unseal (Verify + Decrypt) ─────────────────────────────────────────────────
async function unseal(envelope, password, signingPublicKey) {
const body = Buffer.from(envelope.body, 'base64');
const signature = Buffer.from(envelope.signature, 'base64');
// Verify signature before touching the ciphertext
const sigOk = crypto.verify(null, body, signingPublicKey, signature);
if (!sigOk) throw new Error('Signature verification failed: envelope was tampered');
// Parse body layout: salt (16) || iv (12) || ciphertext (n-28) || authTag (16)
const salt = body.subarray(0, 16);
const iv = body.subarray(16, 28);
const authTag = body.subarray(body.length - 16);
const ciphertext = body.subarray(28, body.length - 16);
const key = await deriveEncryptionKey(password, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(plain.toString());
}
// ── Demo ──────────────────────────────────────────────────────────────────────
const { privateKey, publicKey } = await generateSigningKeys();
const password = 'correct horse battery staple';
const original = {
user: 'alice',
action: 'transfer',
amount: 500,
ts: 1750161600
};
console.log('Original payload:', original);
console.log('\nSealing...');
const envelope = await seal(original, password, privateKey);
console.log('Envelope keys:', Object.keys(envelope));
console.log('Body length (bytes):', Buffer.from(envelope.body, 'base64').length);
console.log('Signature length (bytes):', Buffer.from(envelope.signature, 'base64').length); // 64
console.log('\nUnsealing...');
const recovered = await unseal(envelope, password, publicKey);
console.log('Recovered:', recovered);
console.log('Match:', JSON.stringify(original) === JSON.stringify(recovered)); // true
// Demonstrate tamper detection
console.log('\nTesting tamper detection...');
const tampered = { ...envelope, body: envelope.body.slice(0, -4) + 'XXXX' };
try {
await unseal(tampered, password, publicKey);
} catch (err) {
console.log('Tamper detected:', err.message);
// "Signature verification failed: envelope was tampered"
}
Expected output when you run the file:
Original payload: { user: 'alice', action: 'transfer', amount: 500, ts: 1750161600 }
Sealing...
Envelope keys: [ 'version', 'body', 'signature' ]
Body length (bytes): 76
Signature length (bytes): 64
Unsealing...
Recovered: { user: 'alice', action: 'transfer', amount: 500, ts: 1750161600 }
Match: true
Testing tamper detection...
Tamper detected: Signature verification failed: envelope was tampered
8 Common Pitfalls
Pitfall 1: Reusing an AES-GCM Nonce
AES-GCM nonce (IV) reuse with the same key is catastrophic. If you encrypt two different messages with the same key and IV, an attacker XORs the two ciphertexts to cancel the keystream and recovers both plaintexts plus the authentication key. Generate a fresh crypto.randomBytes(12) IV for every single encryption call, and never derive IVs from counters without careful overflow handling. For high-volume systems encrypting billions of messages per key, rotate the key before the nonce space exhausts.
Pitfall 2: Using Math.random() for Security Values
Math.random() uses a predictable pseudorandom sequence. An attacker who can observe a handful of tokens generated with Math.random() can reconstruct the internal seed and predict all future values, breaking session tokens, CSRF tokens, and password reset links in minutes. Always use crypto.randomBytes(), crypto.randomUUID(), or crypto.randomInt() for any security-sensitive value.
Pitfall 3: Comparing Secrets with === or Buffer.compare()
String comparison with === and Buffer.compare() both exit early on the first differing byte. This creates a timing oracle. Replace all security-sensitive comparisons with crypto.timingSafeEqual(). Both buffers must be the same length before calling it. If they differ in length, return false immediately, since leaking the expected length is acceptable when the correct length is fixed and publicly documented.
Pitfall 4: Importing crypto Without the node: Prefix
An npm package named crypto exists as a browser compatibility shim. Without the node: prefix, npm resolution may resolve to that package instead of the built-in, particularly in older projects with broad browser fields in their package.json. Always write import crypto from 'node:crypto' or const crypto = require('node:crypto'). The node: prefix is available since Node.js 14.18.
Pitfall 5: Using PBKDF2 to Store Passwords
PBKDF2 is a key derivation function, not a password hashing function. It resists brute force through iteration count but is not memory-hard. An attacker with a commodity GPU cluster can run hundreds of thousands of PBKDF2 threads in parallel. For storing passwords and comparing them at login, use Argon2id as your first choice, or bcrypt as an established alternative. Reserve PBKDF2 for deriving AES encryption keys from passwords.
Pitfall 6: Passing a Hash Algorithm to Ed25519 sign()
Ed25519 handles hashing internally (SHA-512). Passing a hash name like 'sha256' as the first argument to crypto.sign() with an Ed25519 key throws: Error [ERR_OSSL_EVP_CTRL_NOT_SUPPORTED]: ctrl not supported. Always pass null for Ed25519 and Ed448. Always pass the hash name for ECDSA and RSA. The two APIs look identical but behave differently based on key type.
Pitfall 7: Calling Sync Crypto Functions in Request Handlers
crypto.generateKeyPairSync() for RSA-4096 takes 300 to 800 milliseconds and blocks the Node.js event loop during that entire window. All incoming HTTP requests queue up. Use crypto.generateKeyPair() (the promisified async form) in all request-handling code. Similarly avoid crypto.pbkdf2Sync() and crypto.scryptSync() in production web servers. Both run in the libuv thread pool when called asynchronously and do not block.
Pitfall 8: Extracting the Auth Tag at the Wrong Offset
When you concatenate salt || iv || ciphertext || authTag into a single blob, the auth tag lives at the end, not the beginning. A common mistake is computing body.subarray(0, 16) for the auth tag when that position contains the salt. Draw a byte-offset diagram before implementing any binary framing format. Write a unit test that modifies each byte of the stored blob and confirms decryption fails; this catches offset bugs before production.
8 Troubleshooting Items
Error: error:06065064:digital envelope routines:EVP_DigestVerifyFinal:invalid signature
Check three things: (1) you are passing the public key to verify(), not the private key; (2) the message bytes are byte-for-byte identical on both sides. Check for newline differences, encoding mismatches (UTF-8 vs Latin-1), or trimming; (3) the signature encoding is consistent (both sides use hex, or both use base64). Log Buffer.from(sig).toString('hex') and message.toString('hex') on both sides to compare.
Error: Invalid initialization vector
AES-GCM works best with 12-byte IVs (96 bits). AES-CBC requires exactly 16 bytes. If you generate a 16-byte IV and pass it to AES-GCM, Node.js silently accepts it but uses a slower GCM counter derivation path. If you generate a 12-byte IV and pass it to AES-CBC, you get this error. Check the cipher name and match the IV size accordingly: 12 bytes for any -gcm cipher, 16 bytes for any -cbc cipher.
Error: Unsupported state or unable to authenticate data
The AES-GCM authentication tag did not match. Either the ciphertext was modified (tampering), the auth tag is read from the wrong position in your buffer, or you called decipher.setAuthTag() after calling decipher.update() (it must be set before the first update() call). Verify your byte layout with explicit console.log(buffer.subarray(start, end).toString('hex')) statements on both the encrypt and decrypt paths.
Error: error:0308010C:digital envelope routines::unsupported
This is the OpenSSL 3 legacy provider error. It appears when code uses algorithms that OpenSSL 3 disabled by default: MD5 in signature contexts, RC4, DES, or key sizes below FIPS minimums. Node.js 18+ ships with OpenSSL 3. The short-term workaround is NODE_OPTIONS=--openssl-legacy-provider, but the correct fix is to upgrade the algorithm in your code. MD5 and DES have no place in new applications.
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]
crypto.timingSafeEqual() requires both inputs to be the same byte length. The most common cause is comparing raw bytes on one side against a hex string on the other. Decode hex strings with Buffer.from(str, 'hex') before comparing. Check both a.length and b.length before the call and return false if they differ.
TypeError: Cannot read properties of undefined (reading ‘createCipheriv’)
You are importing the npm crypto shim package instead of the Node.js built-in. Run npm ls crypto to check if it appears in your dependency tree. Change the import to import crypto from 'node:crypto'. If a dependency pulls in the shim, patch it with npm overrides in package.json: "overrides": { "crypto": "npm:node-crypto-polyfill@^1.0.0" } or file an issue with the dependency to remove the shim.
scrypt: maxmem exceeded
With N=131072 and r=8, scrypt needs 128 MB of RAM. If your container (Lambda, Cloud Run, Docker with a memory limit) has less than 256 MB available, the call fails. Reduce N to 65536 (64 MB) and update the maxmem value to 67108864. Always set maxmem explicitly to at least 128 * N * r bytes, or Node.js uses a 32 MB default cap that is too small for production N values.
FIPS mode: Ed25519 not supported
As of June 2026, Ed25519 and X25519 are not approved in OpenSSL’s FIPS module. If your deployment sets crypto.setFips(true) or uses --openssl-fips, Ed25519 key generation throws a FIPS provider error. Use ECDSA P-256 for signing and ECDH P-256 for key exchange in FIPS environments. Check crypto.getFips() at startup and select algorithms accordingly, logging a warning if FIPS mode is active and Curve25519 functions are requested.
Advanced Tips
Use KeyObject Instead of PEM Strings
PEM strings are text and will appear in logs, stack traces, and JSON.stringify() output. KeyObject instances, returned by omitting the encoding options from generateKeyPair(), are opaque JavaScript objects. They cannot be serialized accidentally and do not appear in string interpolation. Pass them directly to crypto.sign(), createCipheriv(), and createHmac(): no PEM parsing overhead, no accidental logging risk.
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const gen = promisify(crypto.generateKeyPair);
// No encoding options = KeyObject instances (opaque, not serializable)
const { privateKey, publicKey } = await gen('ed25519');
console.log(privateKey.type); // 'private'
console.log(publicKey.type); // 'public'
console.log(privateKey.asymmetricKeyType); // 'ed25519'
// Works directly -- no PEM parsing
const sig = crypto.sign(null, Buffer.from('hello'), privateKey);
console.log(crypto.verify(null, Buffer.from('hello'), publicKey, sig)); // true
// Cannot be accidentally leaked
console.log(JSON.stringify({ key: privateKey })); // {"key":{}}
X25519 Key Exchange via the KeyObject API
X25519 (Diffie-Hellman over Curve25519) is faster than P-256 ECDH and avoids the legacy createECDH API. The newer crypto.diffieHellman() function, available since Node.js 13, uses KeyObject pairs and is the preferred approach in Node.js 22:
import crypto from 'node:crypto';
import { promisify } from 'node:util';
const gen = promisify(crypto.generateKeyPair);
const alice = await gen('x25519'); // KeyObject pair
const bob = await gen('x25519');
// Each side computes the shared secret from their private key + the other's public key
const aliceShared = crypto.diffieHellman({
privateKey: alice.privateKey,
publicKey: bob.publicKey
});
const bobShared = crypto.diffieHellman({
privateKey: bob.privateKey,
publicKey: alice.publicKey
});
console.log('Secrets equal:', aliceShared.equals(bobShared)); // true
console.log('Shared secret length:', aliceShared.length, 'bytes'); // 32
Deriving Multiple Independent Keys from One Secret
Never use the same key material for encryption and authentication. Use HKDF with distinct info labels to split one master secret into as many independent keys as you need. Version the labels so you can rotate the derivation scheme without breaking old encrypted data:
import crypto from 'node:crypto';
function splitKeys(masterSecret, salt) {
const derive = (label) => Buffer.from(
crypto.hkdfSync('sha256', masterSecret, salt, Buffer.from(label), 32)
);
return {
encKey: derive('v1:enc'),
macKey: derive('v1:mac'),
tokenKey: derive('v1:token')
};
}
const master = crypto.randomBytes(32);
const salt = crypto.randomBytes(32);
const { encKey, macKey, tokenKey } = splitKeys(master, salt);
// All three are cryptographically independent despite sharing the same source
console.log('All differ:', ![encKey, macKey, tokenKey]
.every((k, i, arr) => i === 0 || k.equals(arr[i - 1]))); // true
Key Derivation Function Comparison
| Function | Memory Hard | Best For | Node.js API | OWASP 2023 Recommendation |
|---|---|---|---|---|
| Argon2id | Yes | Password storage | argon2 package | First choice |
| scrypt | Yes | Password storage, key derivation from password | crypto.scrypt() | Acceptable |
| bcrypt | Partial | Password storage (legacy) | bcrypt package | Acceptable |
| PBKDF2-SHA256 | No | Deriving AES keys from passwords | crypto.pbkdf2() | 600,000 iterations minimum |
| HKDF | No | Expanding strong key material into multiple keys | crypto.hkdfSync() | N/A (not for passwords) |
Production Security Checklist
The OWASP Cryptographic Storage Cheat Sheet and the March 2026 Node.js security bulletin both highlight the same failure mode: correct algorithm, incorrect implementation. Four rules cover 80% of production vulnerabilities.
- Encrypt then MAC: compute the authentication tag over the ciphertext, not the plaintext. AES-GCM does this automatically. If you use AES-CBC, apply HMAC-SHA256 to the IV and ciphertext before returning the result to any caller.
- Separate keys: never reuse the same key for encryption and authentication. Use HKDF to derive independent keys from a single master secret.
- Plan key rotation: include a version field in every encrypted blob so you can decrypt old data with the old key and re-encrypt on next access without a migration batch job.
- Audit algorithm selection: restrict your codebase to AES-256-GCM or ChaCha20-Poly1305 for symmetric encryption. Reject any cipher name ending in
-ecb. Runcrypto.getCiphers()to see what your runtime supports and block ECB variants with an allowlist.
Update Node.js to v20.20.2, v22.22.2, or v24.14.1 before deploying any application that handles TLS connections. Release support timelines are tracked at endoflife.date/nodejs. Node.js 20 reaches end of life in April 2026. For the full reference on every function in this guide, see the official Node.js crypto module documentation. The OWASP Top 10 lists Cryptographic Failures as the second most critical web application security risk.
Related Coverage
Go Deeper on Node.js Security
- AES-256 Encryption in Node.js: 12 Steps [2026], covering AES-GCM encrypt/decrypt pipeline with streaming support and key wrapping
- HMAC-SHA256 in Node.js: 10 Steps, 20 Min [2026]: message authentication codes, keyed hashing, and request signing
- RSA Encryption in Node.js: 11 Steps [2026]: OAEP encryption and PSS signing with 2048 and 4096-bit keys
- Argon2 Password Hashing in Node.js: 11 Steps [2026]: memory-hard password storage, the OWASP first-choice algorithm
- bcrypt Password Hashing in Node.js: 11 Steps [2026]: battle-tested password hashing used in production since 1999
- Digital Signatures Explained: How They Work and Why Hashes Matter: theory behind Steps 8 and 9
- SHA-256 Explained: How It Works and Why It Matters: internal structure of the hash function used throughout
Frequently Asked Questions
What is the Node.js crypto module?
The Node.js crypto module is a built-in library that wraps OpenSSL to provide hashing, HMAC, symmetric encryption, asymmetric encryption, digital signatures, and key derivation. It is available without any npm packages. Import it with import crypto from 'node:crypto' in ES modules or const crypto = require('node:crypto') in CommonJS. The module is maintained by the Node.js security team and receives patches with every Node.js security release.
Should I use the built-in node:crypto or an npm package like crypto-js?
For server-side Node.js code, always use node:crypto. It is maintained by the Node.js security team, runs as native C++ bindings with no JavaScript overhead, and benefits from every OpenSSL patch. The crypto-js npm package targets browser environments where node:crypto is unavailable. On a server, crypto-js adds over 500 KB to your bundle, runs a pure-JavaScript implementation that is 10 to 100 times slower, and does not receive the same security scrutiny as the built-in.
Is Ed25519 or ECDSA P-256 better for digital signatures in Node.js?
Ed25519 is faster (roughly 2x for signing and verification), produces smaller signatures (64 bytes fixed vs 70-72 bytes variable for P-256), and is deterministic, with no random number needed at signing time. Use Ed25519 for new systems unless you have a specific compliance requirement for NIST curves. ECDSA P-256 is required in FIPS 186-5 compliance contexts because Ed25519 is not yet approved in OpenSSL’s FIPS module as of June 2026.
What is the difference between PBKDF2 and scrypt?
Both derive a key from a password through repeated computation. Scrypt is memory-hard: each derivation attempt requires sequential access to a large block of RAM (128 MB at N=131072), which defeats GPU and ASIC parallelism. PBKDF2 is only computation-hard and can be parallelized across many GPU threads simultaneously. Use scrypt for new key derivation from passwords. Use PBKDF2 only when memory constraints prevent scrypt or when FIPS compliance requires it (PBKDF2 is FIPS 800-132 approved; scrypt is not).
When should I use the WebCrypto API instead of the legacy node:crypto API?
Use globalThis.crypto.subtle when you write isomorphic code that must run in both the browser and Node.js without modification, or when strict key usage enforcement matters (a WebCrypto key created for encryption cannot be used accidentally for signing). For server-only code, the legacy node:crypto functions are more ergonomic, support more algorithms (BLAKE2, Diffie-Hellman legacy modes, certificate operations), and have a more complete async/streaming interface.
How do I generate a secure API key in Node.js?
Use crypto.randomBytes(32).toString('hex') for a 64-character hex string (256 bits of entropy), or crypto.randomBytes(24).toString('base64url') for a 32-character URL-safe base64 string (192 bits of entropy). Both are sufficient to make brute-force attacks computationally infeasible. Store API keys hashed in the database using SHA-256 (crypto.createHash('sha256').update(apiKey).digest('hex')) so a database breach does not expose valid keys. SHA-256 is appropriate here because the key already has 256 bits of entropy, unlike passwords, it does not need PBKDF2 or bcrypt.
Why does crypto.generateKeyPair slow down my Node.js server?
RSA key generation is CPU-intensive. The async crypto.generateKeyPair() runs in the libuv thread pool (4 threads by default via UV_THREADPOOL_SIZE). If more than 4 RSA keys generate simultaneously, the extras queue in the thread pool, delaying the event loop’s next tick callback. Solutions: generate keys at application startup, cache them in memory, use a key management service, or switch to Ed25519 (generates in under 1 millisecond). Increase UV_THREADPOOL_SIZE to 8 or 16 for applications that genuinely need concurrent key generation.
Is AES-256-GCM safe for data at rest?
Yes. AES-256-GCM provides both confidentiality (AES counter mode) and integrity (Galois/Counter Mode 128-bit authentication tag). It is FIPS 140-3 approved and the NIST recommended mode for authenticated encryption. The critical requirement: generate a unique 12-byte nonce for every encryption call with the same key. For workloads exceeding 2^32 encryptions per key (roughly 4 billion), rotate the key to prevent nonce space exhaustion. Use HKDF to derive a unique key per file or per record for large-scale encryption at rest.




