Crypto, Passwords & Secrets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Node.js ships cryptography as the node:crypto module, a JavaScript layer over OpenSSL (all three lines run OpenSSL 3.5.x in the builds used for this page), plus the browser-compatible Web Crypto API at globalThis.crypto. node:crypto and the Web Crypto API are both Stability 2 (Stable), but individual corners are not: crypto.createMac() is a Release candidate (1.2), the raw key formats and OpenSSL STORE key loading are Active development (1.1), the "modern algorithms" of Web Crypto (Argon2, ML-KEM, ML-DSA, ChaCha20-Poly1305, SHA-3) and Ed448/X448 are Active development (1.1), and a handful of legacy functions are Deprecated (0). The goal of this page is practical: pick the right primitive for each job, use it with parameters that are still acceptable in 2026, and keep secrets out of code, logs and URLs. It builds on Buffers & Binary Data (every crypto function takes and returns Buffer`s) and on CLI Options, Environment & Config File (--env-file`); how sessions and tokens are issued on top of these primitives is in Authentication & Sessions, and the wider threat model is in Security Best Practices. This page is a practical guide, not a substitute for a security review: choose the primitive from the table below, prefer the high-level recipe over assembling primitives, and never invent your own scheme.

Choosing the right tool

Most mistakes in cryptography code are not broken algorithms but the right algorithm used for the wrong job. The table below maps a goal to the primitive this page recommends and to the one people commonly misuse in its place.

Table 1. Which primitive for which goal
Goal Use Do not use

Unpredictable identifiers, session ids, tokens, salts, IVs

crypto.randomUUID(), crypto.randomBytes(), crypto.randomInt()

Math.random(), Date.now(), counters

Fingerprint or checksum of public data, content addressing

crypto.hash(), crypto.createHash('sha256')

MD5 or SHA-1 where collisions matter

Prove that a message came from someone holding a shared key (webhooks, signed cookies)

crypto.createHmac() plus crypto.timingSafeEqual()

hash(secret + message), comparing with ===

Store a user password

crypto.argon2() (Argon2id, 24.7+), else crypto.scrypt(); PBKDF2 only for FIPS-bound systems

createHash() of any kind, bcrypt on new code, encryption

Encrypt data you must read back later

AES-256-GCM through createCipheriv() (or Web Crypto AES-GCM)

createCipher() (removed), AES-CBC or CTR without a MAC, ECB, a fixed IV

Prove who produced a message, with a public key anyone can check

crypto.sign() / crypto.verify() with Ed25519 (or ECDSA P-256)

Raw RSA "encrypt with the private key" (privateEncrypt())

Turn a secret or a shared secret into one or more keys

crypto.hkdf() (high-entropy input), crypto.scrypt() / crypto.argon2() (passwords)

Hashing the secret once with SHA-256

The stability and version map

Table 2. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

node:crypto module

2 Stable

Long-standing; a JavaScript layer over OpenSSL. All examples here use the node: prefix.

crypto.randomBytes(), randomInt(), randomUUID()

Stable

randomUUID() added in v15.6.0 and v14.17.0, randomInt() in v14.10.0.

crypto.randomUUIDv7()

Stable (no separate marker)

The documentation says added in v26.1.0. Observed: it is also present on 24.21.0 and absent on 22.23.3.

crypto.createHash(), createHmac()

Stable

Long-standing. The outputLength option for XOF hash functions such as shake256 since v12.8.0.

crypto.hash() (one-shot)

Stable

Added in v21.7.0 and v20.12.0; no longer experimental in v25.5.0 and v24.13.1. The options-object form (outputLength) arrived in v24.4.0, so on 22.23.3 only the string outputEncoding form works (observed: an object throws ERR_INVALID_ARG_TYPE).

crypto.createMac(), crypto.getMacs()

1.2 Release candidate

Added in v26.9.0 (KMAC and friends). Observed: undefined on 24.21.0 and 22.23.3.

crypto.scrypt(), scryptSync()

Stable

Added in v10.5.0; the cost, blockSize and parallelization option names (aliases N, r, p) in v10.9.0.

crypto.argon2(), argon2Sync()

Stable (no separate marker)

Added in v24.7.0 (Argon2d, Argon2i, Argon2id). Observed: typeof crypto.argon2 is 'undefined' on 22.23.3, 'function' on 24.21.0 and 26.10.0.

crypto.pbkdf2(), pbkdf2Sync()

Stable

Added in v0.5.5; the digest argument is mandatory since v8.0.0.

crypto.timingSafeEqual()

Stable

Added in v6.6.0; accepts ArrayBuffer since v15.0.0. Throws RangeError on different lengths.

createCipheriv(), createDecipheriv()

Stable

createCipher() and createDecipher() were End-of-Life in v22.0.0 (DEP0106). Short GCM tags without an explicit authTagLength became an error in v26.0.0 (DEP0182).

crypto.generateKeyPair(), sign(), verify()

Stable

ML-DSA added in v24.6.0, ML-KEM key pairs in v24.7.0, SLH-DSA in v24.8.0, the Ed25519 context parameter in v26.0.0.

crypto.hkdf(), crypto.diffieHellman()

Stable

hkdf() added in v15.0.0; diffieHellman() in v13.9.0 and v12.17.0.

Raw key formats ('raw-public', 'raw-private', 'raw-seed')

1.1 Active development

Described under "Raw key formats" in the crypto API page.

crypto.fips property, crypto.setEngine()

0 Deprecated

Replaced by crypto.getFips() / crypto.setFips() (DEP0093) and by OpenSSL providers (DEP0183, doc-only since v22.4.0 and v20.16.0).

Web Crypto: crypto.subtle, crypto.getRandomValues()

2 Stable

Global crypto added in v17.6.0 and v16.15.0, no longer behind a flag in v19.0.0 and no longer experimental in v23.0.0; standards-based algorithms including Ed25519 and X25519.

Web Crypto: Argon2, ML-KEM, ML-DSA, ChaCha20-Poly1305, SHA-3, Ed448, X448

1.1 Active development

Listed under "Modern Algorithms" and "Secure Curves" in the Web Crypto page. Observed: using them prints an ExperimentalWarning.

Random values and UUIDs

crypto.randomBytes() returns bytes from the operating system’s cryptographically secure generator (through OpenSSL); it is the source for every secret you create: session identifiers, API tokens, password-reset links, salts and initialization vectors. crypto.randomInt() picks a uniformly distributed integer in a range without the modulo bias you get from randomBytes()[0] % n. crypto.randomUUID() returns a version 4 UUID built from the same generator, which is fine as an unguessable identifier; crypto.randomUUIDv7() returns a version 7 UUID that begins with a millisecond timestamp, so values sort by creation time and index better in a database, at the price of revealing when the row was created. Math.random() is never acceptable for anything an attacker must not predict. Each function has a synchronous form and a callback form; the callback form runs on the libuv thread pool and is rarely needed for small sizes.

Random values: UUIDs, tokens, PINs
import * as crypto from 'node:crypto';
const { randomUUID, randomBytes, randomInt } = crypto;

console.log('uuid v4  ', randomUUID());
console.log('uuid v7  ', typeof crypto.randomUUIDv7 === 'function' ? crypto.randomUUIDv7() : '(needs Node.js 26)');
console.log('token    ', randomBytes(32).toString('base64url').length, 'chars of base64url');
console.log('pin      ', String(randomInt(0, 1_000_000)).padStart(6, '0'));

// Asynchronous form: the callback runs after the bytes are produced on the thread pool.
randomBytes(16, (err, buf) => {
  if (err) throw err;
  console.log('async    ', buf.length, 'bytes');
});
Output on Node.js 26 (UUIDs shortened)
uuid v4   649f5468-…
uuid v7   01a10405-…
token     43 chars of base64url
pin       <six random digits>
async     16 bytes

A 32-byte token encodes to 43 base64url characters (no padding, safe in URLs and cookies). Sixteen random bytes (128 bits) is the usual floor for an unguessable identifier; 32 bytes is the usual choice for a bearer token or an encryption key. To generate a key once, from a shell, without writing it to a script:

Generating a key from the command line
node -p "crypto.randomBytes(32).toString('hex')"

The disableEntropyCache option of randomUUID() is about performance: by default Node.js generates enough random data for 128 UUIDs and caches it; pass { disableEntropyCache: true } only if you measure a reason to.

Hashing

A hash function maps any input to a fixed-size digest; the same input always yields the same digest and it is infeasible to find another input with the same digest. Hashes provide integrity of public data: file checksums, content-addressed storage, cache keys, commit ids. A hash on its own does not authenticate anything (anyone can recompute it) and it is the wrong tool for passwords, because it is built to be fast (see Password storage). SHA-256 and SHA-512 (or SHA-3) are the choices for new code; MD5 and SHA-1 are broken for collision resistance and survive only for non-security checksums and legacy protocols. crypto.getHashes() lists the names OpenSSL offers.

Node.js gives you three forms: the one-shot crypto.hash(algorithm, data[, outputEncoding]) (fastest for small inputs, no object to manage), the incremental createHash() returning a Hash that you can update() repeatedly, and Hash as a stream, so a file can be hashed through pipeline() without loading it into memory (see Streams).

One-shot, incremental and streamed hashing, plus an HMAC
import { createHash, hash, createHmac, timingSafeEqual } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

// One-shot (stable since 25.5.0 / 24.13.1; added 21.7 / 20.12) and incremental forms agree.
const oneShot = hash('sha256', 'bookshelf');
const incremental = createHash('sha256').update('book').update('shelf').digest('hex');
console.log(oneShot.slice(0, 4) + '…' + oneShot.slice(-4), oneShot === incremental);
console.log(hash('sha256', 'bookshelf', 'base64url').length, 'chars as base64url');
// XOF functions take an output length (outputLength option, added in 24.4.0)
console.log(createHash('shake256', { outputLength: 8 }).update('bookshelf').digest().length, 'bytes of shake256');

// Hash a file as a stream
const h = createHash('sha256');
await pipeline(createReadStream(new URL(import.meta.url)), h);
const d = h.digest('hex');
console.log('file sha256', d.slice(0, 4) + '…' + d.slice(-4));

// HMAC with a key from the environment, verified in constant time
const key = Buffer.from(process.env.WEBHOOK_SECRET_HEX, 'hex');
const sign = (body) => createHmac('sha256', key).update(body).digest();
const body = JSON.stringify({ event: 'book.created', id: 7 });
const received = sign(body); // normally taken from a request header
const expected = sign(body);
console.log('hmac ok', received.length === expected.length && timingSafeEqual(received, expected));
console.log('hmac forged', timingSafeEqual(sign('{"event":"book.deleted"}'), expected));
Output on Node.js 26, 24 and 22 (identical), run with node --env-file=.env hash.mjs
c636…d0f5 true
43 chars as base64url
8 bytes of shake256
file sha256 9eb3…f382
hmac ok true
hmac forged false

A few details worth knowing. Strings passed to update() or hash() are encoded as UTF-8 unless you give an input encoding; for anything else, hash the Buffer explicitly (the crypto page has a section on caveats of using strings as inputs, such as differing normalisation of the same visible text). The digest() method can be called once; calling update() afterwards throws ERR_CRYPTO_HASH_FINALIZED. hash.copy() clones the running state, which is how you produce several digests that share a long prefix. And the hash() one-shot function accepts the file’s bytes as a Buffer, so for large files prefer the streamed form above.

HMAC: authenticating a message with a shared key

An HMAC is a hash keyed with a secret: only holders of the key can produce or verify the tag. Use it for webhook signatures, signed cookies, CSRF tokens and any "this request came from us" check where both sides share a key. Three rules make it safe, all visible in the example above:

  • The key is random bytes from randomBytes(), at least as long as the hash output (32 bytes for SHA-256), loaded from the environment and never written in the source. WEBHOOK_SECRET_HEX came from node -p "crypto.randomBytes(32).toString('hex')".

  • Never build the construction yourself. hash(secret + message) is exposed to length-extension attacks with Merkle-Damgard hashes such as SHA-256. createHmac() is the correct construction.

  • Compare tags with crypto.timingSafeEqual(), not ===. A normal string comparison stops at the first differing byte, and the time it takes can leak how many leading bytes were right. timingSafeEqual() takes the same time wherever the difference is. It throws a RangeError when the two buffers have different lengths, so check the lengths first (the length of a tag is not secret) as the example does.

In Node.js 26, crypto.createMac() (Stability 1.2, Release candidate, added in v26.9.0) generalises this to further MAC algorithms such as KMAC, and crypto.getMacs() lists what the build supports. Because it does not exist on Node.js 24 and 22, use createHmac() for portable code.

Password storage

A user’s password must never be stored, logged or emailed in a recoverable form. What you store is the output of a password hashing function: a deliberately slow, salted, and ideally memory-hard function, so that an attacker who steals the database can only test guesses slowly and expensively. The OWASP Password Storage Cheat Sheet is the reference used on this page; at the time of writing it ranks the choices as follows.

Table 3. OWASP minimum configurations (Password Storage Cheat Sheet, checked 2026-10-04) and the Node.js call
Algorithm OWASP parameters Node.js API

Argon2id (first choice)

Equivalent profiles: m=47104 (46 MiB), t=1, p=1; m=19456 (19 MiB), t=2, p=1; m=12288 (12 MiB), t=3, p=1; m=9216 (9 MiB), t=4, p=1; m=7168 (7 MiB), t=5, p=1

crypto.argon2('argon2id', parameters) with message, nonce, memory, passes, parallelism, tagLength; Node.js 24.7+

scrypt (when Argon2id is unavailable)

N=2^17 (128 MiB), r=8, p=1; or N=2^16, r=8, p=2; N=2^15, r=8, p=3; N=2^14, r=8, p=5; N=2^13, r=8, p=10

crypto.scrypt(password, salt, keylen, options) with N, r, p, maxmem; every supported line

PBKDF2 (when FIPS-140 validated implementations are required)

PBKDF2-HMAC-SHA256: 600,000 iterations; PBKDF2-HMAC-SHA512: 220,000 iterations; SHA-1 only for legacy

crypto.pbkdf2(password, salt, iterations, keylen, 'sha256')

bcrypt (legacy systems only)

Work factor 10 or more; passwords limited to 72 bytes

Not in node:crypto; needs an npm package

OWASP’s advice on tuning is to pick the strongest profile your login endpoint can afford: hashing a password should take well under a second on your production hardware, but long enough that a GPU cluster cannot afford billions of attempts. Measure on the real machine and raise the cost over time (hardware gets cheaper), and store the parameters next to each hash so old and new hashes can coexist. Every password hash needs its own random salt of at least 16 bytes, generated with randomBytes(). A pepper (a secret shared by all hashes, kept outside the database, for example as an HMAC key applied after hashing) adds defence in depth if the database leaks without the application secrets, but OWASP stresses that it does not replace a good algorithm, and rotating it forces every user to reset their password.

scrypt: the portable choice

crypto.scrypt() is available on every supported line. Its default parameters (N=16384, r=8, p=1, from the docs) are well below the current OWASP minimum, and the memory it needs is roughly 128 * N * r bytes. The maxmem option caps that memory and defaults to 32 MiB; asking for more raises an error (observed: ERR_CRYPTO_INVALID_SCRYPT_PARAMS) rather than silently using less, so raise maxmem together with N. The next example stores a self-describing record, so that the parameters travel with the hash, and it verifies with timingSafeEqual().

Password hashing and verification with scrypt (async, OWASP N=2^17)
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

// OWASP minimum for scrypt: N=2^17, r=8, p=1. That needs 128 * N * r = 128 MiB,
// so maxmem must be raised above its 32 MiB default (it is an error otherwise).
const PARAMS = { N: 2 ** 17, r: 8, p: 1, keylen: 64 };
const maxmem = 256 * 1024 * 1024;

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const { N, r, p, keylen } = PARAMS;
  const key = await scryptAsync(password, salt, keylen, { N, r, p, maxmem });
  // Self-describing record: algorithm, parameters, salt and hash travel together.
  return ['scrypt', N, r, p, salt.toString('base64'), key.toString('base64')].join('$');
}

export async function verifyPassword(password, record) {
  const [alg, N, r, p, saltB64, keyB64] = record.split('$');
  if (alg !== 'scrypt') return false;
  const expected = Buffer.from(keyB64, 'base64');
  const actual = await scryptAsync(password, Buffer.from(saltB64, 'base64'), expected.length, {
    N: Number(N), r: Number(r), p: Number(p), maxmem,
  });
  return timingSafeEqual(actual, expected); // both are Buffers of the same length
}

if (import.meta.main) {
  const password = process.env.EXAMPLE_PASSWORD;
  let t = performance.now();
  const record = await hashPassword(password);
  console.log(`hash took ${Math.round(performance.now() - t)} ms`);
  console.log(record.slice(0, 22) + '…' + record.slice(-6));
  console.log('right password:', await verifyPassword(password, record));
  console.log('wrong password:', await verifyPassword(password + 'x', record));
}
Output on Node.js 26 (run with node --env-file=.env passwords.mjs; the salt and hash are truncated)
hash took 174 ms
scrypt$131072$8$1$wQdc…DrWQ==
right password: true
wrong password: false

On the machine used for this page (macOS, arm64) the OWASP profile took about 160 to 175 ms on all three lines. import.meta.main (Node.js 24.2+ and 22.18+) runs the demo only when the file is the entry point, so the same file can be imported as a module. In a real service the record belongs in a database column (see Databases from Node.js), and the login handler should return the same generic error for "unknown user" and "wrong password" and run a hash against a dummy record for unknown users, so response time does not reveal which accounts exist.

Argon2id: the first choice on Node.js 24.7 and later

crypto.argon2() and crypto.argon2Sync() were added in v24.7.0 and expose Argon2d, Argon2i and Argon2id. Use 'argon2id'. The parameters object has required fields with their own names: message is the password, nonce is the salt (at least 8 bytes, 16 or more recommended), memory is the memory cost in 1 KiB blocks, passes is the iteration count (OWASP’s t), parallelism is the lane count (p) and tagLength is the output size. Optional secret is a pepper and associatedData binds extra context. Availability was verified on the real binaries on 2026-10-04: typeof crypto.argon2 is 'function' on Node.js 26.10.0 and 24.21.0 and 'undefined' on 22.23.3, so feature-detect it if you still support 22.

Argon2id (OWASP profile m=19 MiB, t=2, p=1) and PBKDF2-HMAC-SHA-256
import * as crypto from 'node:crypto';
import { promisify } from 'node:util';

const { randomBytes, pbkdf2, timingSafeEqual } = crypto;
const password = process.env.EXAMPLE_PASSWORD;

// Argon2id with an OWASP-listed profile: m=19 MiB, t=2, p=1 (memory is in 1 KiB blocks)
if (typeof crypto.argon2 === 'function') {
  const argon2 = promisify(crypto.argon2);
  const nonce = randomBytes(16);
  const params = { message: password, nonce, memory: 19456, passes: 2, parallelism: 1, tagLength: 32 };
  const t = performance.now();
  const tag = await argon2('argon2id', params);
  console.log(`argon2id ${Math.round(performance.now() - t)} ms, ${tag.length} bytes`);
  const again = await argon2('argon2id', { ...params, message: password });
  console.log('same inputs match:', timingSafeEqual(tag, again));
} else {
  console.log('crypto.argon2 is not available on', process.version);
}

// PBKDF2: only when FIPS-validated primitives are required. 600,000 iterations of HMAC-SHA-256.
const pbkdf2Async = promisify(pbkdf2);
const t2 = performance.now();
const dk = await pbkdf2Async(password, randomBytes(16), 600_000, 32, 'sha256');
console.log(`pbkdf2-sha256 ${Math.round(performance.now() - t2)} ms, ${dk.length} bytes`);
Output on Node.js 24 and 26 (timings vary; on 22 the first line is the "not available" message)
argon2id 14 ms, 32 bytes
same inputs match: true
pbkdf2-sha256 43 ms, 32 bytes

Two observations on these numbers, both from this run. First, the same inputs produce the same tag, which is why the nonce must be random per password and stored with the hash. Second, a profile that finishes in about 15 ms on a fast laptop is OWASP’s minimum memory-and-time trade-off, not a target: if your login path can afford 100 ms, raise memory and passes accordingly. The promise-returning form comes from util.promisify() (see The util Module); both argon2() and scrypt() can also be called through their Sync variants, which you should only do in scripts.

PBKDF2 parameters

PBKDF2 is neither memory-hard nor GPU-resistant, so it is the third choice, kept for environments that require FIPS-140 validated primitives. Its work factor is the iteration count and it depends on the inner hash: OWASP lists 600,000 for HMAC-SHA-256 and 220,000 for HMAC-SHA-512. Use pbkdf2() with the digest argument spelled out (omitting it was deprecated in v6.0.0 and it has been mandatory since v8.0.0), a random salt of 16 bytes or more, and an output length no longer than the digest size (32 bytes for SHA-256), because asking for more only helps the defender’s CPU and not the attacker’s. The Web Crypto form (subtle.deriveBits() with the PBKDF2 algorithm) is shown in Web Crypto.

Why you want the asynchronous forms

scrypt(), argon2() and pbkdf2() run on the libuv thread pool (four threads by default, adjustable with UV_THREADPOOL_SIZE), so the event loop stays free while a derivation takes place; the Sync variants run on the main thread and freeze every connection for the whole duration. The program below measures how many 10 ms timer ticks fire while one synchronous and four concurrent asynchronous derivations run.

Sync versus async scrypt: the timer shows whether the event loop is free
import { scrypt, scryptSync, randomBytes } from 'node:crypto';

const salt = randomBytes(16);
const opts = { N: 2 ** 16, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };
let ticks = 0;
const timer = setInterval(() => ticks++, 10);

// Sync: the event loop is frozen for the whole derivation
let t = performance.now();
scryptSync('example', salt, 64, opts);
console.log(`sync   : ${Math.round(performance.now() - t)} ms, timer ticks during it: ${ticks}`);

// Async: derivations run on the libuv thread pool (default 4 threads) and the loop keeps ticking
ticks = 0; t = performance.now();
await Promise.all(Array.from({ length: 4 }, () => new Promise((res, rej) =>
  scrypt('example', salt, 64, opts, (e, k) => (e ? rej(e) : res(k))))));
console.log(`async x4: ${Math.round(performance.now() - t)} ms, timer ticks during it: ${ticks}`);
clearInterval(timer);
Observed on Node.js 26 (macOS, arm64)
sync   : 93 ms, timer ticks during it: 0
async x4: 110 ms, timer ticks during it: 11

Four parallel derivations took about as long as one synchronous one because they ran on four threads, and the loop kept ticking. Remember that the thread pool is shared with fs, dns.lookup() and zlib: a burst of logins can delay file reads, so cap concurrency (a small queue or a semaphore) and rate-limit the endpoint. The event loop details are in The Event Loop and Don’t Block the Event Loop.

Comparing secrets with timingSafeEqual

A password check, an HMAC check and an API-token check all compare a secret-derived value with an attacker-influenced value. === and Buffer.compare() return as soon as they find a difference; crypto.timingSafeEqual(a, b) examines every byte. The function requires two Buffer, TypedArray, DataView or ArrayBuffer values of identical byte length and throws RangeError otherwise. Two practical patterns follow:

  • Compare derived values of fixed length. Hash or HMAC both sides first (for example with SHA-256) so both buffers are 32 bytes, then compare with timingSafeEqual(); this also hides the length of the secret.

  • Do not "fix" the exception by catching it and returning false on a length mismatch of secrets that must stay private; for public lengths (an HMAC tag) the explicit length check in the earlier examples is fine.

Upgrading hashes over time

Because each record carries its algorithm and parameters, you can migrate without a flag day. On every successful login, check whether the stored record uses the current algorithm and cost; if not, hash the plaintext you hold in memory at that moment with the current settings and overwrite the record. Records of users who never log in stay on old parameters, so after a grace period force a reset (OWASP’s suggestion). The same trick migrates from bcrypt (verify with the old library, store an Argon2id record on success, drop the dependency when the old records are gone).

Rehash on login (sketch built on the earlier functions)
import { hashPassword, verifyPassword } from './passwords.mjs';

const CURRENT = 'scrypt$131072$8$1$'; // algorithm and parameters of today's policy

export async function login(user, password) {
  if (!(await verifyPassword(password, user.passwordRecord))) return false;
  if (!user.passwordRecord.startsWith(CURRENT)) {
    user.passwordRecord = await hashPassword(password); // persist this to the database
  }
  return true;
}

Authenticated encryption with AES-256-GCM

Hashing cannot be reversed; encryption can. When the application must read the data again (an access token for a third-party service, a personal identifier, a secret in a vault), encrypt it with an authenticated cipher: one that protects both confidentiality and integrity, so that any modification of the ciphertext is detected. AES-256-GCM is the standard choice and is what createCipheriv('aes-256-gcm', …​) provides. A GCM message is made of four things you must handle:

  • The key: exactly 32 random bytes, never a password and never hard-coded. Here it comes from DATA_KEY_HEX (generate it with the command in Random values and UUIDs); if your starting point is a passphrase, derive the key with scrypt or Argon2id (see the vault example below).

  • The IV (nonce): 12 random bytes per message, generated with randomBytes(). It is not secret and is stored with the ciphertext. Reusing an IV with the same key destroys GCM’s security, so never use a counter you could lose or a constant. With random 96-bit IVs, rotate the key long before 2^32 messages.

  • The authentication tag: 16 bytes, produced by cipher.getAuthTag() after final() and given back with decipher.setAuthTag() before final(). If the tag does not match, final() throws, and you must discard any plaintext already produced. Since Node.js 26.0.0 a tag shorter than 16 bytes is rejected unless you declare authTagLength (DEP0182, End-of-Life), so declare it on both sides as below.

  • The additional authenticated data (AAD): optional context (a user id, a record id, a format version) that is authenticated but not encrypted. It binds a ciphertext to where it belongs, so an attacker cannot copy user 7’s encrypted value into user 8’s row.

AES-256-GCM encrypt and decrypt with IV, tag and AAD
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

// 32 random bytes, hex-encoded in the environment. Generate one with:
//   node -p "crypto.randomBytes(32).toString('hex')"
const key = Buffer.from(process.env.DATA_KEY_HEX ?? '', 'hex');
if (key.length !== 32) throw new Error('DATA_KEY_HEX must be 64 hex characters (a 256-bit key)');

export function encrypt(plaintext, aad = '') {
  const iv = randomBytes(12); // 96-bit nonce: NEVER reuse an IV with the same key
  const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
  cipher.setAAD(Buffer.from(aad));
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return Buffer.concat([iv, tag, ciphertext]).toString('base64url'); // iv | tag | ciphertext
}

export function decrypt(token, aad = '') {
  const raw = Buffer.from(token, 'base64url');
  const iv = raw.subarray(0, 12);
  const tag = raw.subarray(12, 28);
  const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
  decipher.setAAD(Buffer.from(aad));
  decipher.setAuthTag(tag); // required BEFORE final(); final() throws if anything was altered
  return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
}

if (import.meta.main) {
  const token = encrypt('library card 0042', 'user:7');
  console.log('token:', token.slice(0, 8) + '…' + token.slice(-4), `(${token.length} chars)`);
  console.log('roundtrip:', decrypt(token, 'user:7'));

  const tampered = Buffer.from(token, 'base64url');
  tampered[tampered.length - 1] ^= 1; // flip one bit of the ciphertext
  try { decrypt(tampered.toString('base64url'), 'user:7'); }
  catch (e) { console.log('tampered ciphertext ->', e.message); }
  try { decrypt(token, 'user:8'); }
  catch (e) { console.log('wrong AAD          ->', e.message); }
}
Output on Node.js 26, 24 and 22 (the token differs on every run and is truncated)
token: HTif…SVpt (60 chars)
roundtrip: library card 0042
tampered ciphertext -> Unsupported state or unable to authenticate data
wrong AAD          -> Unsupported state or unable to authenticate data

The error message is the same for a flipped bit, a wrong key and a wrong AAD, and it should stay that way in what you return to callers: distinguishing the cases helps an attacker. The 60-character token is 12 bytes of IV, 16 of tag and 17 of ciphertext, base64url encoded. Authenticated encryption protects the bytes, not their freshness: GCM does not stop an attacker from replaying an old valid ciphertext, so include a version, timestamp or sequence number in the AAD if replay matters. To support key rotation, prefix the stored value with a short key identifier and keep old keys available for decryption only.

Encrypting with a passphrase: a password vault done right

A password manager is the classic exercise, and the part that most often goes wrong is the storage: the secrets it protects are saved in plain text, or "encrypted" with a key that sits beside them. The safe design derives the key from the user’s master passphrase with a memory-hard function, encrypts the whole vault with AES-256-GCM, and stores only the salt, IV, tag, ciphertext and the KDF parameters. Nothing in the file lets an attacker test a guess without paying the full scrypt cost for each one.

A sealed vault: scrypt-derived key plus AES-256-GCM
import { scrypt, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);
const KDF = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };

// Password manager storage: a master passphrase protects a JSON vault. Nothing is stored in plain text.
export async function seal(vault, masterPassphrase) {
  const salt = randomBytes(16);
  const iv = randomBytes(12);
  const key = await scryptAsync(masterPassphrase, salt, 32, KDF);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const data = Buffer.concat([cipher.update(JSON.stringify(vault), 'utf8'), cipher.final()]);
  return JSON.stringify({ v: 1, kdf: 'scrypt', N: KDF.N, r: KDF.r, p: KDF.p,
    salt: salt.toString('base64'), iv: iv.toString('base64'),
    tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') });
}

export async function open(file, masterPassphrase) {
  const f = JSON.parse(file);
  const key = await scryptAsync(masterPassphrase, Buffer.from(f.salt, 'base64'), 32,
    { N: f.N, r: f.r, p: f.p, maxmem: KDF.maxmem });
  const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(f.iv, 'base64'));
  decipher.setAuthTag(Buffer.from(f.tag, 'base64'));
  const json = Buffer.concat([decipher.update(Buffer.from(f.data, 'base64')), decipher.final()]);
  return JSON.parse(json.toString('utf8'));
}

if (import.meta.main) {
  const master = process.env.EXAMPLE_PASSWORD;
  const file = await seal({ library: { user: 'reader-1', secret: '<example-secret>' } }, master);
  console.log('stored fields:', Object.keys(JSON.parse(file)).join(', '));
  console.log('contains the plain text?', file.includes('reader-1'));
  console.log('opened:', (await open(file, master)).library.user);
  try { await open(file, master + '!'); } catch (e) { console.log('wrong passphrase ->', e.message); }
}
Output on Node.js 26, 24 and 22
stored fields: v, kdf, N, r, p, salt, iv, tag, data
contains the plain text? false
opened: reader-1
wrong passphrase -> Unsupported state or unable to authenticate data

For large files, Cipheriv and Decipheriv are streams, so they fit into pipeline() with createReadStream() and createWriteStream() (see Streams); with GCM the tag is only available after the stream ends, so write it after the ciphertext and read it back from the end of the file. The decrypted output of a GCM stream must be considered untrusted until final() succeeds, which means writing it to a temporary file and renaming it only on success. When the key must never be accessible to JavaScript at all (for example an HSM or a cloud key-management service), call that service instead of holding the key in memory.

Key pairs, signatures and key agreement

Public-key cryptography uses a key pair: the private key stays secret and the public key may be shared. crypto.generateKeyPair() (and generateKeyPairSync()) creates KeyObject pairs, and crypto.sign() / crypto.verify() produce and check signatures. A signature proves that someone holding the private key approved exactly these bytes, which suits release signing, signed webhooks to many receivers, license files and tamper-evident logs. Choose Ed25519 by default (fast, small, no parameters to get wrong); ECDSA with P-256 and SHA-256 when a standard or an old peer demands it; RSA-PSS with 3072 bits or more for legacy interoperability. For Ed25519 the algorithm argument of sign() and verify() must be null, because the hash is part of the scheme. For ECDSA name the hash and prefer dsaEncoding: 'ieee-p1363' (a fixed-size r || s signature, the same shape Web Crypto and JWTs use) over the default DER encoding.

Treat a KeyObject as the unit you pass around: it can be exported to PEM, DER or JWK when you need to store it, and the docs advise creating one KeyObject and reusing it, because import has a cost. Never print or log an exported private key. The example writes an encrypted private key to a temporary file with owner-only permissions and prints only booleans and lengths. (The page text shows no key material on purpose; a private key committed anywhere, or a PEM block pasted in a chat or ticket, must be treated as compromised and rotated.)

Ed25519 and ECDSA signatures, key persistence, X25519 plus HKDF, ML-DSA
import * as crypto from 'node:crypto';
import { writeFileSync, readFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey, diffieHellman, hkdfSync } = crypto;

// 1. Ed25519 signatures: the algorithm argument must be null for Ed25519 (the hash is built in)
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const message = Buffer.from('release 1.4.0 of the Bookshelf CLI');
const signature = sign(null, message, privateKey);
console.log('signature bytes:', signature.length);
console.log('valid:', verify(null, message, publicKey, signature));
console.log('altered message:', verify(null, Buffer.from('release 1.4.1'), publicKey, signature));

// 2. Persist keys as PEM files (never print them, never commit them); the private key is encrypted
const dir = mkdtempSync(join(tmpdir(), 'keys-'));
const passphrase = process.env.KEY_PASSPHRASE;
writeFileSync(join(dir, 'signing.key'), privateKey.export({
  type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase,
}), { mode: 0o600 });
writeFileSync(join(dir, 'signing.pub'), publicKey.export({ type: 'spki', format: 'pem' }));
const loaded = createPrivateKey({ key: readFileSync(join(dir, 'signing.key')), passphrase });
console.log('reloaded key verifies:', verify(null, message, createPublicKey(loaded), signature));
console.log('key types:', privateKey.asymmetricKeyType, privateKey.type, publicKey.type);

// 3. ECDSA with SHA-256: the hash is named explicitly; IEEE P1363 gives a fixed-size r||s signature
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const ecSig = sign('sha256', message, { key: ec.privateKey, dsaEncoding: 'ieee-p1363' });
console.log('ecdsa p1363 length:', ecSig.length,
  verify('sha256', message, { key: ec.publicKey, dsaEncoding: 'ieee-p1363' }, ecSig));

// 4. Key agreement: X25519 shared secret, then HKDF to get a proper symmetric key
const alice = generateKeyPairSync('x25519');
const bob = generateKeyPairSync('x25519');
const s1 = diffieHellman({ privateKey: alice.privateKey, publicKey: bob.publicKey });
const s2 = diffieHellman({ privateKey: bob.privateKey, publicKey: alice.publicKey });
const k1 = Buffer.from(hkdfSync('sha256', s1, Buffer.alloc(0), 'bookshelf v1 session key', 32));
const k2 = Buffer.from(hkdfSync('sha256', s2, Buffer.alloc(0), 'bookshelf v1 session key', 32));
console.log('both sides derive the same key:', k1.equals(k2));

// 5. ML-DSA post-quantum signatures (added in 24.6.0, needs OpenSSL 3.5)
try {
  const pq = generateKeyPairSync('ml-dsa-65');
  const pqSig = sign(null, message, pq.privateKey);
  console.log('ml-dsa-65 signature bytes:', pqSig.length, verify(null, message, pq.publicKey, pqSig));
} catch (e) { console.log('ml-dsa-65 unavailable:', e.code ?? e.message); }
Output on Node.js 26 and 24 (on 22 the last line is "ml-dsa-65 unavailable: ERR_INVALID_ARG_VALUE")
signature bytes: 64
valid: true
altered message: false
reloaded key verifies: true
key types: ed25519 private public
ecdsa p1363 length: 64 true
both sides derive the same key: true
ml-dsa-65 signature bytes: 3309 true

Notes on the example. The passphrase for the private-key file comes from KEY_PASSPHRASE in the environment (here a random value in a local .env file); a passphrase protects a key at rest, it does not replace file permissions and secret storage. Raw X25519 output is a shared secret with structure, not a key, so run it through HKDF (crypto.hkdf(), for inputs that already have high entropy) with a context string in info; do not use HKDF on passwords, use scrypt or Argon2id there. ML-DSA (FIPS 204) is the NIST post-quantum signature scheme; it needs OpenSSL 3.5, is available on 24.6+, and produces signatures of about 3.3 KB for ML-DSA-65, so use it only where post-quantum resistance is a requirement. A public key proves nothing about who owns it: signatures are only as trustworthy as the way you obtained the public key (pin it in configuration, or distribute it through a certificate chain; see HTTPS, TLS & HTTP/2).

Web Crypto

The Web Crypto API is the standard, promise-based cryptography interface found in browsers, Deno, Bun and Cloudflare Workers. In Node.js it is the global crypto object (globalThis.crypto, also reachable as require('node:crypto').webcrypto), with crypto.subtle for the operations and crypto.getRandomValues() / crypto.randomUUID() for randomness. It is Stability 2 (Stable) as a whole; the "modern algorithms" part of the WICG proposal and the Ed448 and X448 curves are Stability 1.1 (Active development). The same code can run in the browser and on the server, which matters for isomorphic libraries and for edge runtimes. Differences from node:crypto that matter in practice:

  • Everything is asynchronous and works on ArrayBuffer/Uint8Array; there is no streaming, so hashing or encrypting a multi-gigabyte file needs the streaming node:crypto API.

  • Keys are opaque CryptoKey objects with an extractable flag and a list of usages. A non-extractable key can be used but its bytes can never be exported, which limits the damage of an injection bug.

  • AES-GCM appends the 16-byte tag to the ciphertext (so a 17-byte message seals to 33 bytes); IVs are your responsibility just as in node:crypto.

  • keyObject.toCryptoKey() (added in v23.0.0 and v22.10.0) and cryptoKey conversions let you move between the two APIs.

Web Crypto: digest, AES-GCM, Ed25519, HMAC, PBKDF2 and KeyObject interop
// No import needed: `crypto` is a global (Stability 2 - Stable), and so is `crypto.subtle`.
const { subtle } = globalThis.crypto;
const enc = new TextEncoder();
const hex = (buf) => Buffer.from(buf).toString('hex');

// Digest
const digest = await subtle.digest('SHA-256', enc.encode('bookshelf'));
console.log('sha-256:', hex(digest).slice(0, 4) + '…' + hex(digest).slice(-4));

// Random values and UUIDs
console.log(crypto.getRandomValues(new Uint8Array(8)).length, 'random bytes;', crypto.randomUUID().length, 'char uuid');

// AES-GCM: non-extractable key, 96-bit IV; the 16-byte tag is appended to the ciphertext
const aesKey = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const sealed = await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, enc.encode('library card 0042'));
console.log('sealed bytes:', sealed.byteLength, '(17 plaintext + 16 tag)');
console.log('opened:', new TextDecoder().decode(await subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, sealed)));
try { await subtle.exportKey('raw', aesKey); } catch (e) { console.log('export of non-extractable key refused:', e instanceof DOMException); }

// Ed25519 signing
const { publicKey, privateKey } = await subtle.generateKey('Ed25519', false, ['sign', 'verify']);
const sig = await subtle.sign('Ed25519', privateKey, enc.encode('release 1.4.0'));
console.log('verify:', await subtle.verify('Ed25519', publicKey, sig, enc.encode('release 1.4.0')));

// HMAC
const hmacKey = await subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
const mac = await subtle.sign('HMAC', hmacKey, enc.encode('payload'));
console.log('hmac verify (constant time inside):', await subtle.verify('HMAC', hmacKey, mac, enc.encode('payload')));

// PBKDF2 through Web Crypto (importKey -> deriveBits)
const base = await subtle.importKey('raw', enc.encode(process.env.EXAMPLE_PASSWORD), 'PBKDF2', false, ['deriveBits']);
const bits = await subtle.deriveBits(
  { name: 'PBKDF2', hash: 'SHA-256', salt: crypto.getRandomValues(new Uint8Array(16)), iterations: 600_000 }, base, 256);
console.log('pbkdf2 bits:', bits.byteLength * 8);

// Interop with node:crypto KeyObjects
const { createSecretKey } = await import('node:crypto');
const ko = createSecretKey(crypto.getRandomValues(new Uint8Array(32)));
const ck = ko.toCryptoKey?.({ name: 'AES-GCM' }, true, ['encrypt']);
console.log('KeyObject -> CryptoKey:', ck ? ck.algorithm.name : '(toCryptoKey unavailable)');
Output on Node.js 26, 24 and 22 (the run was executed with node --env-file=.env webcrypto.mjs)
sha-256: c636…d0f5
8 random bytes; 36 char uuid
sealed bytes: 33 (17 plaintext + 16 tag)
opened: library card 0042
export of non-extractable key refused: true
verify: true
hmac verify (constant time inside): true
pbkdf2 bits: 256
KeyObject -> CryptoKey: AES-GCM

The ExperimentalWarning appears only when you touch the Active-development parts (an Argon2, ML-KEM, ML-DSA or SHA-3 algorithm name, or SubtleCrypto.supports(), observed on 26.10.0 and 24.21.0); it did not appear for the algorithms above. Feature-detect with SubtleCrypto.supports() where it exists, and treat the modern algorithms as subject to change. In a browser or edge runtime the same code works unmodified, which is the main reason to choose Web Crypto over node:crypto; inside a Node.js service, node:crypto is richer (streams, scrypt, argon2, PEM handling) and usually the better fit.

A tamper-evident hash chain

A hash chain links records by including the hash of the previous record inside the next one; changing any record changes its hash, which no longer matches the copy stored in its successor, so tampering is detectable. Blockchain tutorials are built on this idea, and it is useful far beyond cryptocurrencies: audit logs, append-only ledgers, software supply-chain transparency logs. The version below fixes the mistakes that toy implementations usually make.

Table 4. What the chain does and does not protect
Property How the example achieves it Limit

Integrity of the order and the content

Each block hash covers index, timestamp, previous hash, nonce and entries; verification recomputes every hash and every link.

Anyone who can rewrite the whole chain can recompute all hashes. Detecting that needs an external anchor (publish the latest hash somewhere the writer cannot alter) or distributed agreement.

Unambiguous hashing

The hashed input is a JSON array in a fixed order, and entries use canonical JSON (keys sorted), so one block always serialises to the same bytes and field boundaries cannot shift.

Concatenating fields as plain strings (index + data + prevHash) lets different blocks collide on the same input; avoid it.

Authenticity of each entry

Each entry is signed with Ed25519; verification checks every signature.

A hash chain alone says nothing about who wrote an entry. The public key still has to be trusted separately.

Cost to rewrite (proof of work)

mine() searches for a nonce giving a hash with leading zeros. Difficulty 4 means four hex zeros, about 65,000 attempts on average.

Proof of work is CPU-bound busy work: run it in a worker_threads worker so the event loop stays responsive (see Worker Threads), and bound the search as the example does.

A hash chain with canonical hashing, signed entries and verification
import { hash, generateKeyPairSync, sign, verify, createPublicKey } from 'node:crypto';

// Canonical JSON: keys sorted recursively, so equal data always serialises to equal bytes.
const canonical = (v) => JSON.stringify(v, (_k, x) =>
  x && typeof x === 'object' && !Array.isArray(x)
    ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : 1)))
    : x);

// A fixed-order array (not an object) is hashed, so field boundaries are unambiguous.
const blockHash = ({ index, timestamp, prevHash, nonce, entries }) =>
  hash('sha256', JSON.stringify([index, timestamp, prevHash, nonce, canonical(entries)]));

const ZERO = '0'.repeat(64);

export function mine(index, prevHash, entries, difficulty, timestamp = Date.now()) {
  const target = '0'.repeat(difficulty); // difficulty = number of leading zero hex digits
  for (let nonce = 0; nonce < 50_000_000; nonce++) { // bounded: never spin forever
    const block = { index, timestamp, prevHash, nonce, entries };
    const h = blockHash(block);
    if (h.startsWith(target)) return { ...block, hash: h };
  }
  throw new Error('nonce space exhausted');
}

export function signEntry(payload, privateKey, publicKeyPem) {
  return { payload, publicKey: publicKeyPem, signature: sign(null, Buffer.from(canonical(payload)), privateKey).toString('base64') };
}

export function verifyChain(chain, difficulty) {
  for (let i = 0; i < chain.length; i++) {
    const b = chain[i];
    if (b.index !== i) return `block ${i}: wrong index`;
    if (b.prevHash !== (i === 0 ? ZERO : chain[i - 1].hash)) return `block ${i}: broken link`;
    if (blockHash(b) !== b.hash) return `block ${i}: contents do not match the stored hash`;
    if (!b.hash.startsWith('0'.repeat(difficulty))) return `block ${i}: proof of work missing`;
    for (const e of b.entries) {
      const ok = verify(null, Buffer.from(canonical(e.payload)), createPublicKey(e.publicKey), Buffer.from(e.signature, 'base64'));
      if (!ok) return `block ${i}: bad signature`;
    }
  }
  return 'valid';
}

if (import.meta.main) {
  const difficulty = 4;
  const { publicKey, privateKey } = generateKeyPairSync('ed25519');
  const pem = publicKey.export({ type: 'spki', format: 'pem' });
  const entry = (n) => signEntry({ from: 'reader-1', book: `book-${n}`, action: 'borrow' }, privateKey, pem);

  const chain = [mine(0, ZERO, [], difficulty)];
  for (let n = 1; n <= 3; n++) chain.push(mine(n, chain.at(-1).hash, [entry(n)], difficulty));

  chain.forEach((b) => console.log(b.index, b.hash.slice(0, 10) + '…', 'nonce', b.nonce));
  console.log('intact chain:', verifyChain(chain, difficulty));

  chain[2].entries[0].payload.book = 'book-999'; // rewrite history
  console.log('edited entry:', verifyChain(chain, difficulty));

  chain[2].entries[0].payload.book = 'book-2';
  chain[2].prevHash = ZERO; // try to cut the link instead
  console.log('cut link:', verifyChain(chain, difficulty));
}
Output on Node.js 26 (nonces and hashes vary on every run)
0 0000a19266… nonce 148277
1 0000fa45b3… nonce 187441
2 000028610d… nonce 27212
3 0000599d6b… nonce 51090
intact chain: valid
edited entry: block 2: contents do not match the stored hash
cut link: block 2: broken link

The same program printed the same three verification lines on Node.js 24 and 22, with different nonces. What to take away: the verifier recomputes everything from the data and trusts none of the stored hashes; the order of the checks reports the first inconsistency; and nothing here is secret, which is why the hash comparison uses plain !== while the HMAC and password checks use timingSafeEqual(). The same structure serves a log you wish to make tamper-evident: skip the proof of work (it exists to make rewriting expensive in a system with no trusted writer) and publish the latest block hash periodically. If you ever need a data structure with proofs for individual entries, use a Merkle tree rather than extending a chain.

Secrets handling

A secret is any value that grants access or decrypts data: passwords, API keys, tokens, signing and encryption keys, database URLs with credentials, webhook secrets. Most incidents are not broken cryptography but leaked secrets: committed to a repository, logged, sent in a URL, baked into an image, printed in a stack trace, or left in a shared CI log. The practices below follow the OWASP Secrets Management Cheat Sheet.

Table 5. Where a secret may and may not live
Place Verdict

Source code, test fixtures, README examples

Never. Use obviously fake placeholders such as <your-api-key> or change-me; secret scanners flag lookalikes.

Git history (even in a deleted file or a closed pull request)

Never. A committed secret must be treated as leaked and rotated, because rewriting history does not undo clones and forks.

A .env file listed in .gitignore, mode 600

Acceptable for local development. Commit a .env.example with names and fake values only.

Environment variables injected by the platform (orchestrator secret, CI secret store)

Acceptable for production, but visible to every child process and to anyone who can read the process environment; scope them narrowly.

A mounted secret file or a secrets manager read at startup (cloud provider vault, Vault, Kubernetes secret)

Preferred for production: access is audited, scoped per service and rotated centrally.

URLs, query strings, redirects, referrers

Never. They land in access logs, browser history, proxies and the Referer header. Send secrets in headers (Authorization) or in a request body.

Logs, error messages, exception reports, analytics

Never. Redact on the way out; keep error messages free of values.

Docker image layers and ARG/ENV instructions

Never. They are readable by anyone who can pull the image; use build secrets or runtime injection (see Docker).

Front-end bundles

Never. Anything shipped to a browser is public; keep secret calls on the server.

Loading configuration: --env-file and its relatives

Node.js loads .env files natively, so the dotenv package is not needed for this job. The --env-file=<file> flag reads the file into process.env before your code runs; --env-file-if-exists=<file> does the same without failing if the file is absent (it prints <file> not found. Continuing without it., observed on 26.10.0); process.loadEnvFile(path) loads a file from code; and util.parseEnv() parses the same format from a string without touching process.env. All four are Stable, no longer experimental since v24.10.0 and v22.21.0, having been added between v20.6.0 and v22.9.0 (see CLI Options, Environment & Config File). Values already present in the real environment are not overridden, so the platform’s secrets win over a stray .env.

Generating and using a local .env file without ever pasting a secret
# Create the file once. The generated values never appear in your shell history or in code.
echo "DATA_KEY_HEX=$(node -p "crypto.randomBytes(32).toString('hex')")" > .env
chmod 600 .env
echo ".env" >> .gitignore

node --env-file=.env server.js

The configuration module below follows three habits that stop the common leaks. Validate every secret once at startup and fail fast with an error that names the setting but never prints its value. Wrap secrets in an object that redacts itself, so a stray console.log(config), JSON.stringify() or template literal cannot disclose it (the custom util.inspect.custom symbol controls what console.log() prints). Parse with util.parseEnv() when you need .env content without mutating the process environment.

A fail-fast configuration module with redacting secrets
import { parseEnv, inspect } from 'node:util';
import { createSecretKey } from 'node:crypto';

// 1. Fail fast: validate every secret once at startup, before serving traffic.
function requireSecret(name, { minBytes = 32, encoding = 'hex' } = {}) {
  const raw = process.env[name];
  if (!raw) throw new Error(`Missing required secret ${name}`); // name only, never the value
  const bytes = Buffer.from(raw, encoding);
  if (bytes.length < minBytes) throw new Error(`${name} must decode to at least ${minBytes} bytes`);
  return bytes;
}

// 2. Keep secrets in objects that refuse to print themselves.
class Secret {
  #value;
  constructor(value) { this.#value = value; }
  reveal() { return this.#value; }
  toString() { return '[redacted]'; }
  toJSON() { return '[redacted]'; }
  [inspect.custom]() { return 'Secret { [redacted] }'; }
}

const config = {
  dataKey: new Secret(createSecretKey(requireSecret('DATA_KEY_HEX'))),
  port: Number(process.env.PORT ?? 3000),
};
console.log(config);
console.log(JSON.stringify(config));
console.log(`dataKey is ${config.dataKey}`);

// 3. Parse a .env-style string without touching process.env (util.parseEnv)
const parsed = parseEnv('API_BASE=https://example.test\nAPI_KEY=<your-api-key>');
console.log(Object.keys(parsed), parsed.API_BASE);

// 4. Missing secret -> clear error that does not leak anything
try { requireSecret('NOT_SET_ANYWHERE'); } catch (e) { console.log(e.message); }
Output on Node.js 26 and 22 (run with node --env-file=.env secrets.mjs)
{ dataKey: Secret { [redacted] }, port: 3000 }
{"dataKey":"[redacted]","port":3000}
dataKey is [redacted]
[ 'API_BASE', 'API_KEY' ] https://example.test
Missing required secret NOT_SET_ANYWHERE

Further practices

  • Rotate. Every secret needs an owner and a rotation procedure that you have rehearsed. Support two active keys at once (the key identifier in the stored ciphertext makes that possible), and rotate immediately when someone leaves or a leak is suspected.

  • Least privilege. Give each service its own credentials with the minimum scope, so a leak of one does not open everything. The Node.js permission model (added in v20.0.0 as experimental, Stable since v23.5.0 and v22.13.0 for file system, child process and worker restrictions; the --allow-net flag added in v25.0.0 is still Stability 1.1, Active development) limits what a process may touch even if it is compromised, see The Permission Model.

  • Scan before it lands. Run a secret scanner (for example detect-secrets or the scanning built into your Git host) in a pre-commit hook and in CI, and treat any hit as a rotation event, not only a cleanup.

  • Avoid secrets on the command line. Arguments are visible to every user of the machine through the process list; prefer files and the environment.

  • Keep them out of child processes. child_process inherits process.env by default; pass an explicit env object to processes that do not need your secrets (see Child Processes).

  • Memory. JavaScript strings cannot be zeroed, and the garbage collector may copy them. Keep key material in Buffer or KeyObject values (which can be overwritten or are held by OpenSSL), keep it for as short as practical, and never put it in diagnostic output or heap snapshots you share.

  • Use platform-issued short-lived credentials where possible (workload identity, OIDC tokens in CI) rather than long-lived static keys.

OWASP password and authentication guidance

Storing the hash correctly is only part of handling passwords. The OWASP Authentication Cheat Sheet and NIST SP 800-63B-4 (the US digital identity guideline OWASP follows) add the policy around the hash. In short, and subject to rechecking the current versions:

  • Length over complexity. Allow a maximum length of at least 64 characters so that passphrases fit, and allow every character including Unicode and whitespace. OWASP, citing NIST SP 800-63B, treats passwords shorter than 15 characters as weak when multi-factor authentication is not enabled. Do not impose composition rules (upper case, digits, symbols) and do not require periodic changes; rotate credentials when a leak or a compromise is identified.

  • Do not truncate silently. Set a generous maximum (to bound hashing cost), tell the user about it, and never rely on a hash function that silently ignores bytes beyond a limit (bcrypt’s 72-byte limit is the well-known example).

  • Check against breached passwords. Reject passwords found in known breach lists (for example through a k-anonymity range API) and common-password lists.

  • Multi-factor authentication. A second factor (passkeys, WebAuthn, TOTP) dramatically reduces the damage of a leaked password.

  • Throttle and log. Rate-limit login attempts per account and per address, use generic error messages, and monitor for credential stuffing; but keep lockouts from becoming a denial-of-service tool against your users.

  • Never email or display a password, and use single-use, expiring, high-entropy tokens (randomBytes(32), stored hashed) for resets.

  • Transport. Passwords and tokens travel only over TLS (see HTTPS, TLS & HTTP/2).

How sessions, cookies, Passport and JSON Web Tokens use these building blocks is covered in Authentication & Sessions; the NestJS counterpart is in NestJS Authentication.

What changed recently

Table 6. Crypto-related changes by release line (from the Node.js API documentation, checked 2026-10-04)
Version Area Change

v26.9.0

MAC

crypto.createMac() and crypto.getMacs() added (Stability 1.2, Release candidate); crypto.hash() accepts algorithms exposed by OpenSSL providers.

v26.1.0

UUID

crypto.randomUUIDv7() documented as added (observed also on 24.21.0).

v26.0.0

GCM tags

DEP0182 End-of-Life: a GCM auth tag shorter than the default 16 bytes now needs an explicit authTagLength in createDecipheriv() (runtime deprecation since v23.0.0). Ed25519 sign()/verify() gained the context parameter.

v25.5.0 and v24.13.1

Hash

crypto.hash() is no longer experimental.

v24.8.0

Signatures

SLH-DSA key pairs and signing, plus context parameters for ML-DSA and Ed448.

v24.7.0

KDF and PQC

crypto.argon2() and argon2Sync() added; ML-KEM key pairs and crypto.encapsulate() / crypto.decapsulate().

v24.6.0

Signatures

ML-DSA key pairs and signing.

v24.4.0

Hash

outputLength for XOF hash functions in crypto.hash().

v23.0.0 and v22.10.0

Web Crypto interop

keyObject.toCryptoKey() added.

v22.21.0 and v24.10.0

Environment

--env-file, process.loadEnvFile() and util.parseEnv() no longer experimental.

v22.4.0 and v20.16.0

Engines

OpenSSL engine support deprecated in documentation (DEP0183) because OpenSSL 3 prefers providers; crypto.setEngine() is Deprecated.

v22.0.0

Legacy

createCipher() and createDecipher() removed (DEP0106): they derived keys with unsalted MD5 and used static IVs.

v21.7.0 and v20.12.0

Hash

crypto.hash() one-shot function added.

The OWASP recommendations move as well. The current Password Storage Cheat Sheet lists 600,000 iterations for PBKDF2-HMAC-SHA-256 and 220,000 for PBKDF2-HMAC-SHA-512 (with HMAC-SHA-1 at 1,400,000, for legacy systems only); those are the figures used throughout this page. Earlier revisions of the same cheat sheet listed 210,000 for HMAC-SHA-512 and 1,300,000 for HMAC-SHA-1, and much older tutorials quote 100,000 or less, so a hash stored with one of those counts should be upgraded on the next successful login (Upgrading hashes over time). Re-read the cheat sheet before hard-coding a number.

Where the books are outdated

Two O’Reilly books were used as references for the topics on this page: Node.js Projects by Jonathan Wexler and Efficient Node.js by Samer Buna (both 2025). They are good for the narrative, but a few practices they show must not be copied into new code.

Table 7. Corrections to common book-era practices
Practice seen in the books Today’s guidance

Password manager example that stores the managed passwords in plain text

Derive a key with scrypt or Argon2id and encrypt the vault with AES-256-GCM, as in Encrypting with a passphrase: a password vault done right.

PBKDF2 with a low iteration count (about twelve thousand), synchronous pbkdf2Sync(), and comparing hashes with ===

OWASP’s current figure is 600,000 iterations for HMAC-SHA-256 (or better, Argon2id), use the async API and crypto.timingSafeEqual().

bcrypt as the go-to library for password hashing

Still acceptable for legacy systems (cost 10+, 72-byte limit), but crypto.argon2() has been built in since Node.js 24.7 and crypto.scrypt() since 10.5, with no native add-on to compile or update.

Hash chains and proof-of-work built from string concatenation, in the request path of the event loop

Hash a canonical, unambiguous encoding, sign entries, verify every link, and run the CPU-bound mining in a worker (A tamper-evident hash chain).

Secrets loaded with a dotenv-style package and committed or logged by accident

node --env-file=.env (stable since v24.10.0 and v22.21.0) needs no package; keep .env out of Git, validate at startup, redact in logs.

Encrypting file streams with aes-256-ctr (in Efficient Node.js), which hides the content but has no integrity protection: a flipped ciphertext bit silently flips the same plaintext bit

Use AES-256-GCM with a random 12-byte IV per stream, store the IV and the 16-byte authentication tag with the ciphertext, and treat output as untrusted until decipher.final() has verified the tag (Authenticated encryption with AES-256-GCM). For large streams whose plaintext must not be consumed before verification, encrypt in independently tagged chunks, or encrypt-then-MAC: compute an HMAC over IV and ciphertext with a separate key and verify it before decrypting.

Creating ciphers with createCipher() (not in these books, but common in older tutorials)

Removed in v22.0.0. Use createCipheriv() with a random IV and an authenticated mode.

Pitfalls and good practice

Table 8. Common crypto mistakes and the fix
Mistake Fix

Math.random() for tokens, ids or salts

crypto.randomBytes(), randomInt() or randomUUID().

A fast hash (sha256, md5) of the password, even salted

A password hashing function: Argon2id, then scrypt, then PBKDF2 with OWASP parameters.

The same salt for every user, or a salt derived from the username

A fresh 16-byte random salt per password, stored with the hash.

Synchronous scryptSync() or pbkdf2Sync() in a request handler

The callback or promisified form, with bounded concurrency; see Why you want the asynchronous forms.

Comparing secrets, tags or tokens with ===

crypto.timingSafeEqual() on equal-length buffers.

AES-CBC or CTR without a MAC, or ECB

AES-256-GCM (or ChaCha20-Poly1305) with a fresh random 12-byte IV per message.

One IV reused for many messages, or an IV derived from the message

randomBytes(12) for each encryption, stored beside the ciphertext.

A password used directly as an AES key

Derive the key with scrypt or Argon2id and a stored random salt.

Building your own construction (hash(secret + message), custom padding, custom protocols)

Use HMAC, AES-GCM, signatures and HKDF as documented, or a vetted high-level library.

Treating the output of crypto.hash() as secret or as proof of origin

A hash proves nothing about who made a message; use an HMAC or a signature.

Leaving a private key, token or .env in Git, an image layer or a log

Rotate it now, then add scanning and .gitignore; see Secrets handling.

Rolling your own JWT or session-token scheme

See Authentication & Sessions for libraries and standards.

References