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.
| Goal | Use | Do not use |
|---|---|---|
Unpredictable identifiers, session ids, tokens, salts, IVs |
|
|
Fingerprint or checksum of public data, content addressing |
|
MD5 or SHA-1 where collisions matter |
Prove that a message came from someone holding a shared key (webhooks, signed cookies) |
|
|
Store a user password |
|
|
Encrypt data you must read back later |
AES-256-GCM through |
|
Prove who produced a message, with a public key anyone can check |
|
Raw RSA "encrypt with the private key" ( |
Turn a secret or a shared secret into one or more keys |
|
Hashing the secret once with SHA-256 |
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Long-standing; a JavaScript layer over OpenSSL. All examples here use the |
|
Stable |
|
|
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. |
|
Stable |
Long-standing. The |
|
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 ( |
|
1.2 Release candidate |
Added in v26.9.0 (KMAC and friends). Observed: undefined on 24.21.0 and 22.23.3. |
|
Stable |
Added in v10.5.0; the |
|
Stable (no separate marker) |
Added in v24.7.0 (Argon2d, Argon2i, Argon2id). Observed: |
|
Stable |
Added in v0.5.5; the |
|
Stable |
Added in v6.6.0; accepts |
|
Stable |
|
|
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. |
|
Stable |
|
Raw key formats ( |
1.1 Active development |
Described under "Raw key formats" in the crypto API page. |
|
0 Deprecated |
Replaced by |
Web Crypto: |
2 Stable |
Global |
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 |
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.
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');
});
Source: Node.js API: crypto.randomBytes(), Node.js API: crypto.randomUUID(), Node.js API: crypto.randomUUIDv7(), Node.js API: crypto.randomInt()
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:
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).
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));
Source: Node.js API: crypto.hash(), Node.js API: crypto.createHash(), Node.js API: crypto.createHmac()
node --env-file=.env hash.mjsc636…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_HEXcame fromnode -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 aRangeErrorwhen 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.
| 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 |
|
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 |
|
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 |
|
bcrypt (legacy systems only) |
Work factor 10 or more; passwords limited to 72 bytes |
Not in |
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().
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));
}
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.
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`);
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.
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);
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).
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()afterfinal()and given back withdecipher.setAuthTag()beforefinal(). 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 declareauthTagLength(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.
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); }
}
Source: Node.js API: crypto.createCipheriv(), Node.js API: crypto.createDecipheriv(), OWASP: Cryptographic Storage Cheat Sheet
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.
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); }
}
Source: Node.js API: crypto.scrypt(), Node.js API: Cipheriv, OWASP: Cryptographic Storage Cheat Sheet
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.)
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); }
Source: Node.js API: crypto.generateKeyPairSync(), Node.js API: crypto.sign(), Node.js API: crypto.verify(), Node.js API: crypto.diffieHellman(), Node.js API: crypto.hkdf()
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 streamingnode:cryptoAPI. -
Keys are opaque
CryptoKeyobjects with anextractableflag 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) andcryptoKeyconversions let you move between the two APIs.
// 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)');
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.
| 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 ( |
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) |
|
Proof of work is CPU-bound busy work: run it in a |
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));
}
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.
| Place | Verdict |
|---|---|
Source code, test fixtures, README examples |
Never. Use obviously fake placeholders such as |
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 |
Acceptable for local development. Commit a |
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 |
Logs, error messages, exception reports, analytics |
Never. Redact on the way out; keep error messages free of values. |
Docker image layers and |
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.
# 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
Source: Node.js API: Command-line options, Node.js API: process.loadEnvFile(), Node.js API: util.parseEnv()
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.
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); }
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-netflag 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-secretsor 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_processinheritsprocess.envby default; pass an explicitenvobject 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
BufferorKeyObjectvalues (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
| Version | Area | Change |
|---|---|---|
v26.9.0 |
MAC |
|
v26.1.0 |
UUID |
|
v26.0.0 |
GCM tags |
DEP0182 End-of-Life: a GCM auth tag shorter than the default 16 bytes now needs an explicit |
v25.5.0 and v24.13.1 |
Hash |
|
v24.8.0 |
Signatures |
SLH-DSA key pairs and signing, plus context parameters for ML-DSA and Ed448. |
v24.7.0 |
KDF and PQC |
|
v24.6.0 |
Signatures |
ML-DSA key pairs and signing. |
v24.4.0 |
Hash |
|
v23.0.0 and v22.10.0 |
Web Crypto interop |
|
v22.21.0 and v24.10.0 |
Environment |
|
v22.4.0 and v20.16.0 |
Engines |
OpenSSL engine support deprecated in documentation (DEP0183) because OpenSSL 3 prefers providers; |
v22.0.0 |
Legacy |
|
v21.7.0 and v20.12.0 |
Hash |
|
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.
| 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 |
OWASP’s current figure is 600,000 iterations for HMAC-SHA-256 (or better, Argon2id), use the async API and |
bcrypt as the go-to library for password hashing |
Still acceptable for legacy systems (cost 10+, 72-byte limit), but |
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 |
|
Encrypting file streams with |
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 |
Creating ciphers with |
Removed in v22.0.0. Use |
Pitfalls and good practice
| Mistake | Fix |
|---|---|
|
|
A fast hash ( |
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 |
The callback or promisified form, with bounded concurrency; see Why you want the asynchronous forms. |
Comparing secrets, tags or tokens with |
|
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 |
|
A password used directly as an AES key |
Derive the key with scrypt or Argon2id and a stored random salt. |
Building your own construction ( |
Use HMAC, AES-GCM, signatures and HKDF as documented, or a vetted high-level library. |
Treating the output of |
A hash proves nothing about who made a message; use an HMAC or a signature. |
Leaving a private key, token or |
Rotate it now, then add scanning and |
Rolling your own JWT or session-token scheme |
See Authentication & Sessions for libraries and standards. |
References
-
Node.js API: Crypto, https://nodejs.org/docs/latest/api/crypto.html
-
Node.js API: crypto.randomBytes(), https://nodejs.org/docs/latest/api/crypto.html#cryptorandombytessize-callback
-
Node.js API: crypto.randomUUID(), https://nodejs.org/docs/latest/api/crypto.html#cryptorandomuuidoptions
-
Node.js API: crypto.randomUUIDv7(), https://nodejs.org/docs/latest/api/crypto.html#cryptorandomuuidv7options
-
Node.js API: crypto.randomInt(), https://nodejs.org/docs/latest/api/crypto.html#cryptorandomintmin-max-callback
-
Node.js API: crypto.hash(), https://nodejs.org/docs/latest/api/crypto.html#cryptohashalgorithm-data-options
-
Node.js API: crypto.createHash(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatehashalgorithm-options
-
Node.js API: crypto.createHmac(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatehmacalgorithm-key-options
-
Node.js API: crypto.createMac(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatemacalgorithm-key-options
-
Node.js API: crypto.scrypt(), https://nodejs.org/docs/latest/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
-
Node.js API: crypto.argon2(), https://nodejs.org/docs/latest/api/crypto.html#cryptoargon2algorithm-parameters-callback
-
Node.js API: crypto.pbkdf2(), https://nodejs.org/docs/latest/api/crypto.html#cryptopbkdf2password-salt-iterations-keylen-digest-callback
-
Node.js API: crypto.timingSafeEqual(), https://nodejs.org/docs/latest/api/crypto.html#cryptotimingsafeequala-b
-
Node.js API: crypto.createCipheriv(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatecipherivalgorithm-key-iv-options
-
Node.js API: crypto.createDecipheriv(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatedecipherivalgorithm-key-iv-options
-
Node.js API: Cipheriv, https://nodejs.org/docs/latest/api/crypto.html#class-cipheriv
-
Node.js API: crypto.generateKeyPairSync(), https://nodejs.org/docs/latest/api/crypto.html#cryptogeneratekeypairsynctype-options
-
Node.js API: crypto.sign(), https://nodejs.org/docs/latest/api/crypto.html#cryptosignalgorithm-data-key-callback
-
Node.js API: crypto.verify(), https://nodejs.org/docs/latest/api/crypto.html#cryptoverifyalgorithm-data-key-signature-callback
-
Node.js API: crypto.diffieHellman(), https://nodejs.org/docs/latest/api/crypto.html#cryptodiffiehellmanoptions-callback
-
Node.js API: crypto.hkdf(), https://nodejs.org/docs/latest/api/crypto.html#cryptohkdfdigest-ikm-salt-info-keylen-callback
-
Node.js API: crypto.createSecretKey(), https://nodejs.org/docs/latest/api/crypto.html#cryptocreatesecretkeykey-encoding
-
Node.js API: keyObject.toCryptoKey(), https://nodejs.org/docs/latest/api/crypto.html#keyobjecttocryptokeyalgorithm-extractable-keyusages
-
Node.js API: Web Crypto API, https://nodejs.org/docs/latest/api/webcrypto.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html
-
Node.js API: process.loadEnvFile(), https://nodejs.org/docs/latest/api/process.html#processloadenvfilepath
-
Node.js API: util.parseEnv(), https://nodejs.org/docs/latest/api/util.html#utilparseenvcontent
-
Node.js API: DEP0106, https://nodejs.org/docs/latest/api/deprecations.html#dep0106-cryptocreatecipher-and-cryptocreatedecipher
-
Node.js API: DEP0182, https://nodejs.org/docs/latest/api/deprecations.html#dep0182-short-gcm-authentication-tags-without-explicit-authtaglength
-
Node.js Learn: Don’t Block the Event Loop, https://nodejs.org/learn/asynchronous-work/dont-block-the-event-loop
-
OWASP: Password Storage Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
-
OWASP: Cryptographic Storage Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
-
OWASP: Secrets Management Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
-
OWASP: Authentication Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
-
NIST SP 800-63B-4: Authentication and Authenticator Management, https://pages.nist.gov/800-63-4/sp800-63b.html
-
RFC 9106: Argon2, Memory-Hard Function for Password Hashing and Proof-of-Work Applications, https://www.rfc-editor.org/info/rfc9106/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/