Published: · Updated: · by kodewright
How Random Is Your 'Random'? CSPRNG Explained (2026)
A CSPRNG is a random source you can't predict from its output. I rebuilt Math.random() from 4 outputs in Node 22. Here's why, and what to use instead.

A CSPRNG (cryptographically secure pseudo-random number generator) is a random source built so that no amount of its output helps you predict the next value or recover the earlier ones. Math.random() is not one. In Node 22, four consecutive Math.random() values were enough for me to rebuild the generator's internal state and predict the following nine values exactly; the script and output are below. For passwords, tokens, salts and session IDs, use crypto.getRandomValues() in the browser and crypto.randomBytes() in Node.
PRNG vs CSPRNG: what actually separates them
Every software generator is pseudo-random: a deterministic algorithm that turns a seed into a stream of numbers that looks random. The difference is what an observer can do with part of that stream.
| Property | PRNG (Math.random() in V8) | CSPRNG (crypto.getRandomValues()) |
|---|---|---|
| Algorithm | xorshift128+ | OS or browser DRBG (ChaCha20, AES-CTR DRBG, Hash_DRBG) |
| State | 128 bits | 256 bits or more, reseeded from OS entropy |
| Predictable from output? | Yes, from about four values | No |
| A leaked state exposes | Every past and future value | Nothing before the leak; reseeding limits what comes after |
| Built for | Speed: games, jitter, sampling | Keys, tokens, salts, nonces |
Formally, a CSPRNG has to pass the next-bit test (seeing the first k bits gives you no better than 50% odds on bit k+1) and resist state compromise (stealing the state today doesn't reveal yesterday's output). xorshift128+ fails both. That's not a defect in V8: the spec never asked Math.random() to be secure, and for shuffling a playlist it's the right tool.
I predicted Math.random() from four outputs
V8, the engine in Chrome, Node and Deno, has used xorshift128+ for Math.random() since 2015. Each step is a few shifts and XORs on two 64-bit words, and each returned double is built from the top 52 bits of one of them. So every output leaks 52 of the 128 state bits, and the algorithm is public. One more detail matters for the attack: V8 generates 64 values at a time into a cache and hands them out in reverse order.
I wrote a short Python script with the Z3 SMT solver (z3-solver 5.3.1 from pip). It treats the state as two unknown 64-bit integers, runs xorshift128+ on them symbolically, requires the top 52 bits of each step to equal the observed values (reversed, because of the cache), and asks Z3 for a state that fits. Stepping that state backwards gives the values Math.random() will return next. The core of it:
a, b = BitVec('a', 64), BitVec('b', 64)
s0, s1 = a, b
for v in reversed(observed):
s0, s1 = s1, xorshift(s0, s1) # one xorshift128+ step, symbolic
S.add(LShR(s0, 12) == mantissa(v)) # top 52 bits = the double's mantissa
S.check() # sat: the state is now known
A run against a fresh Node process, trimmed:
$ node -e 'console.log(JSON.stringify(Array.from({length: 13}, () => Math.random())))'
[0.48530045300870905,0.4535024111129262,0.22613694600298828,0.6116499683877723,0.27607344271000467, ...]
$ python3 predict.py local.json 4
Node v22.23.2: 4 outputs observed, state recovered in 0.02s
predicted 0.27607344271000467 actual 0.27607344271000467 match
predicted 0.71206622404905096 actual 0.71206622404905096 match
predicted 0.26313849711769510 actual 0.26313849711769510 match
...
predicted 0.03887366515100310 actual 0.03887366515100310 match
9/9 exact
I repeated it on nine fresh processes across Node 22.22 and 22.23. With four observed values, every run predicted every following value in the cache exactly, and the solver never took more than a few hundredths of a second. With three values (156 observed bits against 128 unknown ones) Z3 found a state that fit the input but predicted nothing correctly, in all four tries.
The limit of this test: I fed the solver raw doubles. Real code usually leaks something like Math.floor(Math.random() * 62), which reveals about 6 bits per call instead of 52, so an attacker needs more observations and has to deal with the 64-value cache refills. I haven't run that version. It changes how many values the attacker needs to see, not whether the state is recoverable, which is why I treat any secret made from Math.random() as already public.
Where a CSPRNG gets its unpredictability
A CSPRNG is still an algorithm, so the unpredictability has to come from the seed. The operating system collects it from physical noise: interrupt timing, device and I/O jitter, and on most modern CPUs a hardware source such as Intel's RDRAND. That entropy seeds a deterministic generator (ChaCha20 in the Linux kernel since 4.8, an AES-CTR DRBG behind Windows' BCryptGenRandom) that stretches a 256-bit seed into as much output as you ask for, and reseeds itself over time.

crypto.getRandomValues() ends up on that OS facility. Some browsers pass the call through; Firefox runs its own DRBG in NSS, seeded from the OS. Either way there is no fast-but-weak mode you can switch on by accident, which is the property you want from an API that people call without reading the docs.
Turning random bytes into characters without bias
Everything in SecuredKit runs in your browser, and nothing you generate is sent to a server, so the generators needed a secure random source that exists locally. That's crypto.getRandomValues(). Getting secure bytes is the easy half, though. The usual bug is in the next line, where a byte or a 32-bit word gets squeezed into a smaller range with %.
With one byte and a 62-character alphabet, byte % 62 maps 256 values onto 62 slots. 256 isn't a multiple of 62, so the first 8 characters get 5 chances in 256 and the other 54 get 4. Those 8 characters show up 25% more often. The entropy cost is small at this size, about 0.0045 bits per character (a 20-character key drops from 119.08 to 118.99 bits), but it grows as the range gets closer to the source. I measured byte % 200 over 2,000,000 draws in Node 22: the value 0 came up 15,637 times and the value 199 came up 7,886 times, almost exactly the predicted 2:1.
The fix is rejection sampling: throw away draws from the uneven tail and draw again. This is the helper the site's password, passphrase, PIN and alphanumeric-key generators use (trimmed):
const UINT32_RANGE = 2 ** 32
export function randomInts(max: number, count: number): number[] {
// Largest multiple of max that fits in 32 bits; words at or above it are rejected.
const limit = UINT32_RANGE - (UINT32_RANGE % max)
const out: number[] = []
while (out.length < count) {
const words = crypto.getRandomValues(new Uint32Array(Math.min(16_384, count - out.length + 4)))
for (const word of words) {
if (word >= limit) continue
out.push(word % max)
if (out.length === count) break
}
}
return out
}
With 32-bit words and a 62-character alphabet, 2³² mod 62 is 4, so a word is rejected 4 times in about 4.3 billion. There's no performance argument for skipping it. My view: with a 62-character alphabet, modulo bias is a correctness bug rather than a practical break, and I'd still fix it in any code review, because the same % n pattern becomes a real skew the day someone uses it for a range like 200. The Random Number Generator uses the same idea with BigInt for ranges beyond 2⁵³.
How many random bytes are enough
Once the source is a real CSPRNG, the only variable left is how many bits you pull. It's the same formula as password strength:
H = L × log₂(R)
H is entropy in bits, L the number of symbols, and R the number of possible values per symbol. For raw bytes R = 256, so each byte is 8 bits: 16 bytes give 128 bits, 32 bytes give 256. The formula assumes R is honest. A token built from Math.random() can be 40 characters long and still carry close to zero bits against someone who has seen four other outputs from the same process.
| Output from a CSPRNG | Entropy | Use |
|---|---|---|
| 8 bytes | 64 bits | Too short for anything secret |
| 16 bytes | 128 bits | Session IDs, reset tokens, CSRF tokens |
| 32 bytes | 256 bits | API keys, signing secrets, anything long-lived |
For scale: one RTX 4090 runs about 22 billion SHA-256 guesses per second (hashcat 6.2.6). If testing a candidate token cost one SHA-256, searching half of a 64-bit space would take about 13 years on that card, and half of a 128-bit space about 2.5 × 10²⁰ years. Nobody attacks the 128-bit token head-on. They look for the Math.random() behind it. For the password version of this math, see how much entropy a password needs.
Checking your own code
Start with a grep. It finds the call sites that matter in a few seconds:
grep -rnE "Math\.random\(\)" src/ | grep -iE "token|secret|pass|key|otp|salt|nonce|id"
On SecuredKit's own components/, composables/, utils/ and pages/ the unfiltered Math.random() search returns nothing, which is the result you want. For each hit in your code, check two things: the source (crypto.getRandomValues() or crypto.randomUUID() in the browser, crypto.randomBytes() or crypto.randomInt() in Node), and how the bytes become a range (% n without rejection is the bias above).
Statistical batteries such as NIST SP 800-22 (15 tests) or dieharder won't settle the question. xorshift128+ passes most of them and is still fully predictable, as the solver run shows. They're useful for catching a broken conversion step, not for proving a source is secure.
If you find a secret that came from Math.random(), rotate it instead of trying to work out whether anyone noticed: new passwords from the Password Generator, new keys from the Secret Key Generator or crypto.randomBytes(32), and invalidate the old values.
FAQ
How many random bytes do I need for a secure token?
16 bytes (128 bits) is the practical floor for session tokens and IDs. Use 32 bytes (256 bits) for API keys and long-lived secrets. In base64url, 16 bytes encode to 22 characters and 32 bytes to 43.
Is crypto.randomUUID() safe to use as a token?
Yes for unguessability: the Web Crypto spec requires it to use a cryptographically secure source, and a v4 UUID carries 122 random bits. For long-lived API keys I'd still use 32 random bytes, because the extra margin costs nothing.
Is there a limit on how much crypto.getRandomValues() returns at once?
Yes: 65,536 bytes per call. In Node 22, asking for 65,537 throws a QuotaExceededError, so fill large buffers in chunks. Node's crypto.randomBytes() has no comparable limit at normal sizes.