Draw random numbers
Roll dice, pick winners, shuffle lists and make key material from values nobody can predict, with every value in range equally likely.
Use this whenever somebody would love to guess the result: a raffle, who plays whom, a retry delay, a key. The numbers come from the platform's secure generator, and when you ask for a number in a range, every value in it is equally likely, which is harder to get right than it sounds. Concepts explains why Math.random() and % n both fall short.
#Before you start
- It runs on Node.js 22.13 or later (24 or later on the 24 line), Bun, Deno and browsers. Every function is synchronous except
randomJitter, which returns a promise. - Upper bounds are exclusive:
secureRandomNumber(1, 7)returns 1 to 6. Both bounds must be safe integers, and the range at most 2³² values wide. - The draws can't be seeded. To replay a sequence, record the values; you can't regenerate them.
secureShufflereorders the array you pass. Copy it first when the caller still needs the original order.
#Pick winners, roll dice, make a key
Most apps need some mix of these four draws, and each one is a single call.
/** `count` distinct winners. The copy leaves the caller's list in its order. */
export function pickWinners(entries: readonly string[], count: number): string[] {
return secureShuffle([...entries]).slice(0, count);
}
/** One roll of a six-sided die: 1 to 6. The upper bound is exclusive. */
export function rollDie(): number {
return secureRandomNumber(1, 7);
}
/** A delay between 500 and 999 ms, so clients that failed together retry apart. */
export function retryDelayMs(): number {
return secureRandomNumber(500, 1000);
}
/** 32 random bytes: key material for HMAC or encryption. */
export function newKeyMaterial(): Uint8Array {
return secureRandomBytes(32);
}- Winners:
secureShuffleshuffles the array you give it, in place, and hands it back. Shuffling a copy keeps the entry list in sign-up order for the next draw. - Die:
secureRandomNumber(min, max)can returnminbut nevermax, which is why a six-sided die asks for 1 to 7. With one argument, the range starts at 0. - Retry delay: when a burst of clients fails at once, spreading their retries over 500 ms stops them all knocking again in the same millisecond.
- Key material:
secureRandomBytes(32)gives you a freshUint8Array, ready forimportKeyor one of the codecs.
#Skip values or wait a random time
Sometimes you want a range with holes in it, or a pause of unpredictable length. The last argument of secureRandomNumber lists values to skip, and randomJitter waits a random number of milliseconds:
secureRandomNumber(1, 7, [6]); // 1 to 5
await randomJitter(100, 300); // resolves after 100 to 299 msrandomJitter makes a timing difference harder to measure, but the difference is still there underneath. For a check whose timing must not leak at all, see Compare secrets.
#When it fails
Bad bounds and skip lists are setup mistakes, so they throw an UnsecureError. A runtime without working randomness throws PLATFORM:
| Situation | What happens |
|---|---|
max is not greater than min, or secureRandomNumber(0) | Throws OUT_OF_RANGE. |
A bound is fractional, NaN or beyond Number.MAX_SAFE_INTEGER | Throws OUT_OF_RANGE. |
| The range is wider than 2³² values | Throws OUT_OF_RANGE. |
| The skip list covers every value in range | Throws OUT_OF_RANGE, rather than looping forever. |
The skip list is not a Set or an iterable, such as a string | Throws INVALID_TYPE. |
secureRandomBytes length is negative, fractional or 2³¹ or more | Throws OUT_OF_RANGE. |
randomJitter bound is fractional, negative or null, or max is below min | Throws OUT_OF_RANGE synchronously, before any promise exists, so a .catch() on the call never sees it. |
The runtime has no working crypto.getRandomValues | Throws PLATFORM, with the runtime's error as cause. |
#Options
Only a few calls have an argument you can leave out:
| Call | Default | Notes |
|---|---|---|
secureRandomNumber(max) | min is 0 | Returns 0 to max - 1. |
randomJitter() | 0 to 99 ms | randomJitter(max) waits 0 to max - 1, and randomJitter(min, max) waits min to max - 1. When min equals max, it waits exactly min. |
secureShuffle(array, generator) | the shared generator | Takes a private generator, described under Going further. |
resetEntropy({ refill }) | refill: false | true fills the buffer again at once instead of on the next draw. |
#Going further
These come up in tests, in snapshots and at start-up:
- Shared and private buffers:
secureRandomNumber,secureRandomBytesup to 64 bytes,randomJitter,secureShuffleand every UUID draw from one shared 4 KiB buffer of random bytes.const rng = createSecureRandomGenerator()gives you a generator with a 1 KiB buffer of its own, so its draws share nothing with the rest of the process.rng.next(1, 7)draws likesecureRandomNumber, andsecureShuffle(seats, rng)shuffles from it. - Snapshots and checkpoints: a process restored from a memory snapshot wakes up with the same buffer as every other copy, and would replay the same bytes. While Node.js builds a startup snapshot the buffer holds nothing, so Node.js's own snapshots are safe without code. For any other checkpoint, such as a VM snapshot or a serverless warm image, call
resetEntropy()from the platform's restore hook. Also callreset()on each generator you made withcreateSecureRandomGenerator(). - Start-up cost: nothing is allocated until the first draw. Call
resetEntropy({ refill: true })at boot to pay for the firstcrypto.getRandomValuescall before the first request arrives. - Weighted picks: for a loot table or a reward with fixed odds, see Give rewards at the promised rate.