I Predicted Firefox's Math.random(). Don't Use It for OTPs.
Back to Blog

I Predicted Firefox's Math.random(). Don't Use It for OTPs.

I took three Math.random() outputs from Firefox, recovered the internal PRNG state using Z3, and predicted the next values exactly. This is why OTPs must never use Math.random() — it is not random in any security sense.

31 Mar 202605 Mins readBy Ashish Kumar Verma
I Predicted Firefox's Math.random(). Don't Use It for OTPs.

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() playground
Default snippet burns three random calls on purpose.
Last eval result
Run the snippet and watch the values leak.
Observed raw Math.random outputs
Nothing leaked yet.
Attacker panel
Three leaked outputs is where the vibe changes.
3 more leaks needed
In the real writeup, this is where the solver work happens. In the lab, I fast-forward to the punchline so you can test the prediction live.
Predicted next Math.random values
Leak three outputs first.
If the app used Math.random for codes
No predictions yet.
Verification log
Nothing verified yet.
Important honesty note
This is a live sandbox using a Firefox-style xorshift generator and real 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:

  1. OTPs have limited entropy by design.
  2. 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.

OTP digits6
48
Allowed guesses5
110
Search space
1,000,000
possible codes
Entropy
19.93
bits
Blind guess success
0.0005%
5 tries
Why predictability makes this worse
A six digit OTP is already small. If the generator behind it is predictable, an attacker is no longer exploring the full code space. They are following the generator. That is exactly why security sensitive randomness must come from crypto, not 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.

Observed outputs: 3enough to realistically recover state
Captured browser outputs
0.546684418505239453 visible bits
0.137000000000000053 visible bits
0.327000000000000053 visible bits
0.3710000000000000not captured
State accounting
159 bits
visible from observed outputs
128 bits
hidden internal PRNG state
Enough raw information to solve for state
This is the intuition, not a proof. Real recovery still depends on the generator structure, discarded bits, and a constraint solver like Z3. But this is why three samples matter.
Mental model
One output gives you a blurry silhouette. Two outputs narrow the possibilities. Three outputs can pin the machine down hard enough that predicting the next value becomes realistic. That is fine for simulations. It is a terrible property for OTP generation.

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

This section is optional. The main argument is already done. If you skip everything below, the takeaway stays the same. Do not use 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. 🫡

Related Posts