Learn · Technologies we use
Math.random vs crypto.getRandomValues: random numbers in the browser
Why Math.random is wrong for secrets, what the operating system generator does, and the bug our own 2016 code had.
Cryptography runs on numbers nobody can guess: keys, nonces, salts, tokens. A browser gives you two ways to get random numbers, and only one is safe for any of those. Math.random() is a statistical generator, fine for shuffling a slideshow and wrong for anything an attacker would like to predict. crypto.getRandomValues() is the browser's cryptographically secure generator, fed from the operating system. The first version of cipher.ink, in 2016, used the wrong one for the initialization vector of every message. Here's the difference, why it matters, and what each one does.
Two kinds of "random"
A pseudo-random number generator (PRNG) is a formula. It holds some internal state, and each call updates the state and returns a number computed from it. Start from the same state and you get the same sequence forever, which is a feature for simulations, games and animations. A cryptographically secure PRNG (CSPRNG) adds one property: even someone who has seen a very long run of its output can't work out its state or predict the next number, unless they can break a cipher or hash it's built from. It's also seeded from a source of real unpredictability, so nobody can guess the starting state.
"Looks random" isn't the test. Statistical tests ask whether the numbers are evenly spread and uncorrelated. Security asks whether an attacker who knows everything except the secret can predict them. A generator can pass every statistical test and still be completely predictable.
Math.random
The language standard doesn't say which algorithm Math.random() uses, only that the numbers should be roughly uniform. Chrome's engine, V8, used a multiply-with-carry generator called MWC1616 until version 4.9.40. It had only 64 bits of state, produced just 232 distinct values, and failed many statistical tests. The V8 team replaced it in December 2015 (version 4.9.41.0, shipped in Chrome 49 in 2016, with Firefox and Safari switching as well) with xorshift128+, which has 128 bits of state and passes the standard statistical test suite. The V8 team's own post says that even the new one "is still not cryptographically secure" and points to crypto.getRandomValues for encryption and signatures.
The reason is structural. An xorshift generator updates its state with nothing but shifts and XORs, which are linear operations over single bits. That makes it fast, and it also means an observer who has seen a short run of consecutive outputs can find the state by solving linear equations. After that every future (and past) output follows. Write-ups showing this against Chrome's Math.random are public. The practical upshot is that if a site generates session tokens, password-reset links or "random" identifiers with it, an attacker who collects a few of them can predict the rest. We cite the principle and publish no tool.
A side effect of its design is that Math.random can't be seeded. You can't replay a run, which makes it a poor choice for tests and generated art that must reproduce, and no better a choice for secrets. If you need a sequence you can repeat, write or import a small seeded generator and keep it far from anything secret.
crypto.getRandomValues
The Web Crypto API fills a typed array with random bytes: crypto.getRandomValues(new Uint8Array(32)). The standard expects the browser to seed a strong PRNG from an external source, in practice the operating system's generator (/dev/urandom or getrandom() on Linux and macOS, the system generator on Windows), the same source used for TLS keys. A few facts worth knowing:
- It's synchronous and fast, and it's the one member of the crypto API that also works in an insecure (non-HTTPS) context.
- It fills integer arrays only (not floats), and at most 65,536 bytes per call. A larger array throws
QuotaExceededError, so fill big buffers in chunks. - For key generation, prefer
crypto.subtle.generateKey(), which never exposes the bytes to your script at all (the Web Crypto API). crypto.randomUUID()makes a version-4 UUID from the same generator, but only in secure contexts.
What does the operating system's generator do? It collects unpredictable events (interrupt timings, hardware noise, the CPU's own random-number instruction), mixes them into a pool with a hash, and expands the pool with a cryptographic generator, so a small amount of true entropy can feed an effectively endless stream. The strength comes from an unguessable seed at the start and an expansion step nobody can run backwards.
What the 2016 site did
This site is older than its current code. The version that ran from 2016 until October 2026 sealed messages with AES-256 in OFB mode, with the initialization vector picked in the page by a function we still have a copy of:
The 2016 IV generator, as it was.
function make_iv() {
var text = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for( var i=0; i < 16; i++ )
text += possible.charAt(Math.floor(Math.random() * possible.length));
return text;
}Sixteen characters from an alphabet of 62 would give 6216 ≈ 295.3 possibilities if the generator behind them were ideal. It wasn't. It was Math.random, which at the time was either the 64-bit-state MWC1616 or the newly arrived xorshift128+, so the real number of different sequences was far smaller, and the output could be predicted from a handful of values. The IV in OFB isn't secret (it was stored in the open, as the message's own id), so this handed nobody a key. What a weak generator does put at risk is uniqueness. OFB with the same key and the same IV produces the same key stream, and two messages encrypted that way reveal the XOR of their plaintexts. People reuse passphrases, and here the passphrase was the key, padded and never stretched by a key-derivation function (see the OFB article), so an IV collision would have been a real leak. A repeat is unlikely by chance with a 62-letter, 16-character IV, but a design shouldn't have to rest on that argument. The 2026 rebuild draws every salt, IV, key and token from getRandomValues, and a code comment says why.
Rules of thumb
| You need | Use |
|---|---|
| A key, nonce, salt, token or reset link | crypto.getRandomValues (or generateKey) |
| A random integer from 0 to n−1 for a secret | Rejection sampling over getRandomValues, never random % n (modulo bias) |
| A shuffle or a pick in a game or an animation | Math.random is fine |
| A reproducible sequence (tests, procedural graphics) | A seeded PRNG you control; Math.random cannot be seeded |
| A unique id with no secrecy | crypto.randomUUID(), or a counter |
Don't seed anything from the clock. Don't "improve" the output by hashing Math.random, because the entropy is still the 128-bit state, no more. And never reuse a random value for two purposes.
How cipher.ink uses this
Every secret on this site comes from getRandomValues: the 16-byte salt, the 12-byte IV, the secret-link key (32 random bytes) and the receipt token (18 bytes (144 bits)). The passphrase generator and the password generator use it with rejection sampling, so every word is equally likely (passphrase generator). On the server, a message id is 12 characters from a 57-letter alphabet, about 70 bits, drawn from PHP's OpenSSL generator with the same rejection rule. Math.random still turns up in two places in the code, both decorative: the letters that scroll through the cipher animation and the cells that flicker when a message dissolves. Nothing that matters depends on either.