Random Values and UUIDs

Math.random() is a non-cryptographic PRNG whose state is recoverable from a handful of outputs, so a reset token built on it is guessable. Everything security-relevant comes from node:crypto, which draws on the operating system CSPRNG — BCryptGenRandom on Windows, getrandom(2) on Linux. Sixteen random bytes is beyond guessing; 32 is the habit worth having, and base64url fits those in 43 characters.

randomInt(min, max) is not a wrapper around randomBytes. Reducing a random byte with % 10 is biased: 256 is not a multiple of 10, so 0-5 each get 26 of the byte values and 6-9 only 25. randomInt rejects and redraws.

Unguessable identifiers, and modulo bias against rejection samplingJavaScript
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
console.log('session token:', randomBytes(32).toString('base64url'));   // 256 bits
console.log('uuid v4      :', randomUUID());
const biased = new Array(10).fill(0), flat = new Array(10).fill(0);
for (let i = 0; i < 600000; i++) { biased[randomBytes(1)[0] % 10]++; flat[randomInt(10)]++; }
console.log('byte % 10    :', biased.join(' '));
console.log('randomInt(10):', flat.join(' '));
Output
session token: Oi8jTjy7Ju68lZMcAR7RPnVe3vPUCmmGgqJKLJbYRL4
uuid v4      : 7639a8b1-c2a0-47a2-a257-86d6d1116f96
byte % 10    : 61071 61318 60982 60929 60959 60816 58605 58580 58287 58453
randomInt(10): 59779 60190 59741 59789 59896 59997 60302 59876 60556 59874

The biased row's last four buckets fall 4% short of the first six — a real weakness in a coupon-code generator. randomUUID() returns a version 4 UUID: 122 random bits in a 36-character string, 4 bits fixed for the version and 2 for the variant. Node caches "enough random data to generate up to 128 random UUIDs", so 100,000 calls took 24 ms against 184 ms uncached.