Random
Numbers, bytes, shuffles and delays drawn from the platform CSPRNG, batched so the draws are cheap and unbiased.
Note
ELI5. Math.random() is fast, predictable enough to be dangerous, and fine for a loading animation. Everything on this page draws from the operating system's cryptographic randomness instead, and it also avoids a subtler bug: taking a random number modulo a range makes some values more likely than others. These functions reject and redraw instead, so every value in the range is equally likely. Use them for anything an attacker would benefit from predicting.
import { secureRandomNumber, secureRandomBytes, secureShuffle } from "unsecure/random";
secureRandomNumber(100); // an integer in [0, 100)
secureRandomBytes(32); // 32 random bytes
secureShuffle(deck); // shuffled in place#createSecureRandomGenerator()
A buffered CSPRNG. It fills a 256-element Uint32Array with one crypto.getRandomValues call and serves draws out of that, refilling as needed, with rejection sampling so no modulo bias creeps in.
import { createSecureRandomGenerator } from "unsecure/random";
const rng = createSecureRandomGenerator();
rng.next(100); // [0, 100)
rng.next(50, 150); // [50, 150)
rng.next(10, [3, 5, 7]); // [0, 10) excluding 3, 5 and 7
rng.next(50, 100, new Set([75])); // [50, 100) excluding 75next throws OUT_OF_RANGE when max <= min, when the range is wider than 2³², or when the ignore set excludes every value; and INVALID_TYPE when ignore is neither an iterable nor a Set. Every one of those messages names SecureRandomGenerator.next, whichever entry point the draw came through.
#secureRandomNumber()
The same draw, taken from one generator shared by the whole process: one crypto.getRandomValues call per 256 draws.
import { secureRandomNumber } from "unsecure/random";
secureRandomNumber(100); // [0, 100)
secureRandomNumber(50, 150); // [50, 150)
secureRandomNumber(10, [2, 4, 6]); // [0, 10) excluding the evensCreate your own generator when a caller needs isolation. There is no throughput reason to.
#secureRandomBytes()
import { secureRandomBytes } from "unsecure/random";
const key = secureRandomBytes(32); // 256 bits of key material
const iv = secureRandomBytes(12); // a 96-bit AES-GCM nonce
const big = secureRandomBytes(100_000); // chunked internallycrypto.getRandomValues refuses more than 65536 bytes at a time, and this handles the chunking for you. length must be an integer in [0, 2**31 - 1]; anything larger is OUT_OF_RANGE rather than an allocation that runs for hours.
#secureShuffle()
A Fisher-Yates shuffle driven by the CSPRNG. It mutates in place and returns the same array reference.
import { createSecureRandomGenerator, secureShuffle } from "unsecure/random";
const arr = [1, 2, 3, 4, 5];
secureShuffle(arr) === arr; // true
arr; // [1, 5, 3, 2, 4]
// Leave the original alone
const shuffled = secureShuffle([...arr]);
// Reuse one generator across several shuffles
const gen = createSecureRandomGenerator();
secureShuffle(list1, gen);
secureShuffle(list2, gen);#randomJitter()
Waits a random number of milliseconds. Defence in depth against timing side channels: it does not fix a leak, it makes one harder to measure.
import { randomJitter } from "unsecure/random";
await randomJitter(); // 0 to 99 ms
await randomJitter(50); // 0 to 49 ms
await randomJitter(50, 200); // 50 to 199 ms
await randomJitter(undefined, 50); // 0 to 49 ms — an absent lower bound is 0Bounds must be non-negative integers, because setTimeout truncates and a fractional bound never described the delay you got. maxMs === minMs resolves after exactly that many milliseconds without drawing anything. Only an omitted bound takes a default, so null fails the range check:
await randomJitter(1.5);
// UnsecureError OUT_OF_RANGE: randomJitter: maxMs must be an integer >= 0, got 1.5.#Errors
| Cause | code |
|---|---|
max <= min, a range wider than 2³², an ignore set covering the range | OUT_OF_RANGE |
A byte length outside [0, 2**31 - 1], or a fractional jitter bound | OUT_OF_RANGE |
An ignore that is neither an iterable nor a Set | INVALID_TYPE |
#Recipe: a fair draw
import { secureShuffle } from "unsecure/random";
function drawWinners(participants: string[], count: number) {
return secureShuffle([...participants]).slice(0, count);
}The spread matters: without it the caller's array is reordered.
#Recipe: flatten the login timing
import { randomJitter } from "unsecure/random";
async function handleLogin(credentials: Credentials) {
const result = await authenticate(credentials);
// The response time reveals neither whether the account exists
// nor which check failed
await randomJitter(100, 300);
return result;
}Jitter is a supplement to constant-time comparison, not a replacement for it.
#Pitfall: expecting an isolated generator
secureRandomNumber() and randomJitter() share one process-wide buffered generator, so a test that stubs crypto.getRandomValues sees the stub only once every 256 draws.
// Assumes every call reaches crypto.getRandomValues
vi.spyOn(crypto, "getRandomValues").mockImplementation(fill);
secureRandomNumber(100);
// A generator of your own, refilled on its first draw
const rng = createSecureRandomGenerator();
rng.next(100);#Pitfall: mixing in Math.random()
// Defeats the point
const index = Math.floor(Math.random() * tokens.length);
// Draws from the CSPRNG
const index = secureRandomNumber(tokens.length);