I was messing around in the Firefox console when I did something that should make nobody comfortable.
I called Math.random() a few times, copied three outputs into a script, and the script told me what the next random numbers would be.
Then I went back to Firefox, hit enter, and watched those numbers appear exactly as predicted.
Digit for digit.
This was not a magic trick. I was not editing the page. I was not intercepting anything. I was just using the fact that Math.random() is not random in the security sense.
And if you are using Math.random() to generate OTPs, this should be your sign to stop.
the moment this stopped feeling random
The whole demo looked stupidly simple.
I grabbed three Math.random() outputs from Firefox. Then I fed them into a small program using Z3. That program recovered the internal state of Firefox's pseudorandom number generator and started printing the numbers that would come next.
I ran Math.random() again in Firefox.
It matched.
Again.
And again.
That is the part people miss about Math.random(). If you just stare at a decimal like 0.5466844185052394, it looks random enough. Human eyes are very easy to fool here. We see noise and assume unpredictability.
But computers do not care about vibes.
If the generator is deterministic and leaks enough information through its outputs, an attacker can work backwards, recover state, and predict future values.
That is exactly the kind of property you do not want anywhere near OTP generation.
So I wanted the post to let people feel that punch in the stomach directly.
Run the lab below. It literally uses JavaScript eval() with a sandboxed Firefox-style Math.random(). Leak a few outputs, then watch the panel on the right predict what comes next.
Run code. Leak three values. Predict the next ones.
This lab literally uses JavaScript eval() with a sandboxed Firefox-style Math.random(). The real Firefox attack needs state recovery. Here I compress that moment so you can feel the consequence immediately. Once three outputs leak, the right panel starts telling you what comes next.
eval()so readers can feel the attack. The production Firefox exploit still needed proper state recovery with solver logic. The point of the lab is the punch in the stomach once prediction becomes possible, not to ship Z3 in the article.why this is bad even before the Firefox trick
A six digit OTP already lives in a tiny space.
It is just a number from 000000 to 999999.
That is only a million possibilities.
Not a lot.
And the moment you see code like this, alarms should start going off.
function generateOtp() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
This looks normal.
It is still the wrong tool.
An OTP is not a UI flourish. It is not a dice roll in a game. It is not a particle effect.
It is a security boundary.
If someone can predict it, bias it, replay it, or reduce the search space enough to make guessing practical, that becomes an account takeover problem, not a JavaScript trivia problem.
So before we even get into Firefox internals, there are already two issues here:
- OTPs have limited entropy by design.
Math.random()is not meant to protect secrets.
OTPs live in a surprisingly tiny world
Even before we talk about predictable PRNGs, OTPs already have limited entropy. A six digit code is just a number from 000000 to 999999. That is not much room.
Math.random().what math.random actually is
Math.random() is pseudorandom.
That means there is some hidden internal state, some deterministic update rule, and a stream of outputs that only look random from the outside.
The exact implementation depends on the JavaScript engine. Firefox, Chrome, and Node do not all have to use the same thing.
In the Firefox case I was looking at, the generator was based on xorshift128+.
Which sounds fancy but the important part is very simple.
It keeps hidden internal state.
Every call mutates that state in a predictable way.
Then JavaScript shows you part of the result as the floating point number you see in the console.
So Math.random() is not some mystical randomness faucet from the heavens. It is a machine.
And machines can be modeled.
how three outputs become enough
This is the part that broke my brain a little.
Firefox's generator had 128 bits of internal state.
Each Math.random() call leaked about 53 bits of useful information because the returned floating point number is built from a 53 bit mantissa.
So after three observed outputs, you are roughly looking at this:
- first call gives you 53 bits
- second call gives you 53 more
- third call gives you 53 more
That is 159 bits of visible information.
The internal state is 128 bits.
Now obviously this is the intuition, not a formal proof. Some bits are discarded, the constraints are messy, and the exact transition rules matter.
But this is why the whole thing becomes solvable with enough samples.
You are no longer staring at random looking decimals. You are collecting equations.
Three outputs is where it gets uncomfortable
Firefox exposed about 53 usable bits per Math.random() call. The internal xorshift128+ state was 128 bits. Slide the capture count and watch why three outputs are such a big deal.
Once the solver finds the internal state that satisfies those observed outputs, the rest is boring.
You just run the same generator forward.
That is why the script could tell me what Firefox would return next.
And Firefox, very politely, confirmed it.
why this should scare anyone generating OTPs in JavaScript
Because a lot of people still treat Math.random() like it is "good enough" randomness.
It is not.
Not for:
- OTPs
- password reset tokens
- magic links
- invite codes
- session secrets
- CSRF tokens
- anything where predictability becomes compromise
Also, one more thing.
If you are generating the OTP in the browser, the user already controls that environment. They can inspect your code, patch functions, automate requests, and generally behave like a menace.
The OTP should usually be generated on the server using a cryptographically secure source of randomness, stored safely, expired aggressively, and verified server side.
So even if you replace Math.random() with Web Crypto in the browser, that still does not magically make client side OTP generation the right architecture.
what to use instead
If I am generating an OTP in Node.js, I would use crypto.randomInt().
import { randomInt } from "node:crypto";
function generateOtp() {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
If I need secure randomness in the browser, I would use Web Crypto.
function generateOtp() {
const max = 1_000_000;
const range = 0x1_0000_0000;
const limit = range - (range % max);
const buf = new Uint32Array(1);
do {
crypto.getRandomValues(buf);
} while (buf[0] >= limit);
return (buf[0] % max).toString().padStart(6, "0");
}
That rejection loop is there to avoid modulo bias.
Which is exactly the kind of sentence you only get to say when you are using the correct primitive in the first place.
the deep technical dive
info
Math.random() for OTPs.The Firefox demo used a generator from the xorshift family, specifically xorshift128+.
At a high level, the hidden state is just two 64 bit values.
You can think of it like this:
state = (s0, s1)
s0 = 64 bits
s1 = 64 bits
Total hidden state = 128 bits
On each step, the generator mutates that pair with XOR and shift operations. In rough pseudocode, the update looks like this:
newS0 = s1
t = s0 XOR (s0 << 23)
t = t XOR (t >> 17)
t = t XOR s1
t = t XOR (s1 >> 26)
newS1 = t
x = newS0 + newS1
JavaScript does not hand you all of x directly. It exposes a floating point output derived from part of it, which is why you see about 53 useful bits per call.
So the attack intuition becomes:
3 outputs × 53 visible bits each ≈ 159 visible bits
hidden internal state = 128 bits
Again, that does not mean you literally subtract one from the other and call it solved. The constraints are over bit vectors. There are discarded bits. The transition rules have to be modeled correctly.
But it does explain why a solver can recover the hidden state once enough outputs are observed.
The mental model I like is basic school algebra, just more cursed.
If I give you:
x + y = 10
x - y = 2
you can recover both unknowns.
One equation is not enough.
Two starts pinning things down.
Here, each observed Math.random() output gives you another pile of constraints on the unknown state bits. Z3 just does the ugly part for you.
A stripped down sketch of the recovery idea looks like this:
// observed outputs from Firefox
const outputs = [r1, r2, r3];
// unknown internal state
const s0 = BitVec(64);
const s1 = BitVec(64);
// symbolically run the same PRNG transition
// constrain each generated output to match r1, r2, r3
// ask Z3 for any state that satisfies all constraints
const recovered = solve(outputs, s0, s1);
const next = forward(recovered);
That is the whole trick.
Not magic.
Just a deterministic machine being treated like a deterministic machine.
one subtle thing people get wrong
When I tried the same Firefox specific recovery logic against Node, it failed.
That does not mean Node's Math.random() is suddenly safe for OTPs.
It just means the exact Firefox attack was built around the exact Firefox generator.
Different engine.
Different implementation.
Same security lesson.
If something was not designed to be cryptographically secure, do not promote it into a security primitive just because it is convenient.
my honest take
Math.random() is not evil.
It is just being asked to do a job it was never hired for.
Use it for quick simulations.
Use it for animations.
Use it for random colors in a toy app.
Do not use it for OTPs.
The Firefox demo is fun because it feels like a party trick.
But the real lesson is boring and important.
Random looking is not the same as secure.
And in security, that difference is the whole game. 🫡