Skip to content
Your Password Generator

Secure randomness

CSPRNG vs Math.random(): Why Secure Password Generators Use Web Crypto

Why Math.random() is unsafe for passwords, how crypto.getRandomValues() works, and how rejection sampling removes modulo bias. With a tested example.

The short answer

A password generator has one job: produce a value that an attacker cannot guess faster than by trying every possibility. That depends on two things. The random source must be unpredictable, and the mapping from random numbers to characters must be uniform, so that every character is exactly as likely as every other.

In the browser, Math.random() fails the first condition. MDN states directly that it "does not provide cryptographically secure random numbers" and tells developers to use the Web Crypto API instead [2]. crypto.getRandomValues() meets the first condition [1][3]. The second condition is the developer's responsibility, and a common shortcut, randomValue % alphabetLength, quietly breaks it. This guide explains both parts and shows a short, tested fix.

PRNG and CSPRNG: two different promises

What a PRNG is

A pseudo-random number generator (PRNG) is an algorithm that starts from a value called a seed and produces a long sequence of numbers that look random. It is deterministic: the same seed always gives the same sequence. For example, a game that saves its seed can replay the exact same "random" level later.

Determinism is useful for simulations, tests and games. It is a problem for secrets. If someone can work out the seed or the internal state, they can compute every output, past and future. RFC 4086 says of traditional pseudo-random sequences that they "are fully predictable if the initial state is known" [6].

What makes a generator "cryptographically secure"

A cryptographically secure pseudo-random number generator (CSPRNG) is still an algorithm, but it is designed so that seeing its output does not help an attacker predict the next output or recover the state. It must also be seeded with enough real unpredictability, usually collected by the operating system. RFC 4086 summarises the approach: start with a strong random seed, take cryptographically strong steps from it, and do not reveal the complete state [6]. NIST SP 800-90A specifies standard designs of this kind, called deterministic random bit generators (DRBGs) [5].

So "pseudo-random" alone does not mean "weak". The browser's secure generator is also pseudo-random: MDN notes that implementations use "a pseudo-random number generator seeded with a value with enough entropy" rather than a truly random generator, for performance [1]. The difference is in the design goal. A CSPRNG is built to resist prediction. A general PRNG is built to be fast and statistically well distributed.

Why Math.random() is the wrong tool for passwords

Math.random() returns a floating-point number from 0 (inclusive) to 1 (exclusive) with an approximately uniform distribution [2]. The JavaScript engine chooses the algorithm and the seed, and the page cannot set or reset the seed [2]. Nothing in its contract promises unpredictability, and MDN says not to use it "for anything related to security" [2].

Engines pick fast general-purpose algorithms. One documented example: in 2015 the V8 team (Chrome's JavaScript engine) switched Math.random() to xorshift128+. The same post states that it "is still not cryptographically secure" and points to crypto.getRandomValues() for security uses [4]. Other engines may use other algorithms, and any engine can change its algorithm. The safe assumption is the one MDN gives: treat Math.random() as unsuitable for secrets, whatever engine runs the page.

Here is the pattern to avoid. It is shown only so you can recognise it in code review:

// Don't do this: Math.random() is not a cryptographic source.
const ch = alphabet[Math.floor(Math.random() * alphabet.length)];

The output may look perfectly random to a person. That is the trap. Looking random and being unpredictable to an attacker who studies the generator are different properties.

Side by side, the two paths from random source to password characters look like this:

Diagram comparing two paths. Left, Math.random(): a general-purpose PRNG, built to be fast and statistically well distributed, where the engine picks the algorithm and seed; it returns a float from 0 up to 1, approximately uniform, but nothing promises it is unpredictable; result: the wrong tool for passwords, because MDN says it is not cryptographically secure and must not be used for security. Right, crypto.getRandomValues(): a CSPRNG, built to resist prediction and seeded with enough entropy (the specification suggests an operating-system source); it fills a Uint32Array with random 32-bit integers, up to 65,536 bytes per call; rejection sampling discards draws at or above the limit, then takes draw % N; result: uniform characters, each with probability exactly 1/N. Both are pseudo-random; the difference is the design goal: fast and well spread, or unpredictable.
Tap to enlarge

crypto.getRandomValues(): the browser's secure source

The Web Crypto API exposes crypto.getRandomValues(typedArray). You pass an integer typed array, such as a Uint32Array, and the browser fills it with cryptographically strong random values [1]. The W3C specification defines the steps: check that the array is an allowed integer type, throw a QuotaExceededError if it is longer than 65,536 bytes, then fill it with cryptographically secure random bytes [3].

A few practical details matter for generators [1]:

  • It accepts integer arrays only (Uint8Array, Uint32Array and similar), not Float32Array.
  • One call can fill at most 65,536 bytes, which is 16,384 Uint32 values. Larger batches need several calls.
  • It is the only Crypto member that also works in insecure (HTTP) contexts, and it is available in Web Workers.
  • For cryptographic keys, MDN and the specification point to SubtleCrypto.generateKey() instead [1][3]. A password is not a key object, so getRandomValues() is the right tool here.

Where do the bytes come from? The specification says implementations should use well-established cryptographic PRNGs seeded with high-quality entropy, for example from an operating-system source such as /dev/urandom [3]. That is guidance, not a guarantee of one specific design. Browsers differ internally. What matters for a generator is the contract: the values are cryptographically strong [1][3].

From random numbers to characters: uniform selection

Secure random numbers are only half the work. A generator must turn them into characters. Suppose the alphabet is the 62 characters A–Z, a–z, 0–9. You need an index from 0 to 61 where each index has probability exactly 1/62.

Modulo bias, worked example

The obvious approach takes one random byte (0–255) and computes byte % 62. The problem is that 256 is not a multiple of 62:

256 = 4 × 62 + 8

So the 256 byte values cover the 62 indexes four full times, plus 8 extra values (248–255). Those extra values map to indexes 0–7. As a result, indexes 0–7 can each be produced by 5 byte values, and indexes 8–61 by only 4.

With the alphabet above, the characters A to H each appear with probability 5/256 (about 1.95%). Every other character appears with probability 4/256 (about 1.56%). The favoured characters are 25% more likely than the rest. This effect is called modulo bias.

Is that a disaster? No. One character's bias is small, and an attacker cannot exploit it to "decode" a password. But it does mean that the password is not uniformly distributed, so its true strength is slightly lower than the simple formula L × log2(N) suggests. More importantly, it is unnecessary. The fix is simple and costs almost nothing.

Rejection sampling

Rejection sampling means: throw away the random values that cause the unevenness and draw again.

For bytes and a 62-character alphabet, the largest multiple of 62 that fits in 0–255 is 248. Accept a byte only if it is below 248, then take byte % 62. Values 0–247 cover each index exactly 4 times, so the result is exactly uniform. Bytes 248–255 are discarded, which happens 8 times in 256, or about 3% of draws.

Using 32-bit values makes rejection even rarer. There are 2³² possible values. The rejection limit is 2³² − (2³² mod 62). Since 2³² mod 62 = 4, only 4 values out of about 4.29 billion are rejected. In practice almost every draw is accepted, but the code still handles the rare case correctly. This is the same approach our site uses in random.ts [7].

Note one related trap. Some generators enforce "at least one digit, one symbol…" by placing one character from each class and shuffling. That changes the distribution too. A uniform alternative is to draw the whole password from the full alphabet and redraw it if a required class is missing, which is how our generator implements that option [7].

A small, correct example

This function picks characters from an alphabet with crypto.getRandomValues() and rejection sampling. It runs in any modern browser and in Node.js 19 or later, where crypto is available as a global.

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

function randomIndex(n) {
  // Largest multiple of n that fits in 32 bits; draws at or above it are rejected.
  const limit = 2 ** 32 - (2 ** 32 % n);
  const draw = new Uint32Array(1);
  while (true) {
    crypto.getRandomValues(draw);
    if (draw[0] < limit) return draw[0] % n;
  }
}

function generatePassword(length, alphabet = ALPHABET) {
  let password = '';
  for (let i = 0; i < length; i += 1) {
    password += alphabet[randomIndex(alphabet.length)];
  }
  return password;
}

console.log(generatePassword(20));

How it works, line by line: limit is the largest multiple of n that fits in a 32-bit integer. Each draw below limit is reduced with % n, and because the accepted range is an exact multiple of n, every index is equally likely. A draw at or above limit is discarded and the loop asks for a new one. The function never falls back to another source.

This version calls getRandomValues() once per character for clarity. A production generator can fill a larger Uint32Array in one call and loop over it, as long as it stays within the 65,536-byte limit per call [1]. Our implementation does exactly that [7].

With 20 characters from 62, the result has 20 × log2(62) ≈ 119 bits of entropy, assuming uniform selection. You can check other lengths and alphabets with the password entropy calculator.

Why generate on the client

When the password is generated in the browser, the secret never has to cross the network. There is no server request to log, cache or intercept. crypto.getRandomValues() makes this practical, because the browser already provides a cryptographically strong source to every page [1].

Client-side generation has a limit worth stating plainly: the page is only as trustworthy as the code it delivers. That is why a trustworthy generator should be easy to inspect. See how our password generator creates passwords locally in your browser, including the network test you can run yourself. Or go straight to the password generator.

Checklist for reviewing a password generator

When you read a generator's code, look for four things. First, the random source is crypto.getRandomValues(), and there is no Math.random() anywhere in the generation path. Second, if the secure API is missing, the code fails instead of falling back to a weaker source. Third, every mapping from a random number to a range uses rejection sampling or another exactly uniform method, never a plain % n on raw bytes. Fourth, "require each character type" options do not force characters into fixed positions.

Frequently asked questions

Is Math.random() good enough for a "low-stakes" password?

No. The question is not how important the account feels but whether the value is predictable. MDN advises against using Math.random() for anything related to security [2], and the secure alternative is equally easy to call.

Is crypto.getRandomValues() truly random?

Not in the physical sense. It is a cryptographically strong pseudo-random generator seeded with enough entropy [1]. For password generation, that is the property you need: the output cannot practically be predicted.

How big is modulo bias in practice?

It depends on the sizes. With a byte and 62 characters, 8 characters are 25% more likely than the others. With a 32-bit value and 62 characters, the unevenness is tiny, but rejection sampling removes it completely at almost no cost, so there is no reason to keep it.

Can I use crypto.randomUUID() as a password?

A version 4 UUID is built from cryptographically secure random bytes [3], but it has a fixed format and a limited character set. A generator that lets you choose length and characters is a better fit for passwords.

Does rejection sampling slow generation down?

Barely. With 32-bit draws and a 62-character alphabet, only 4 of 2³² values are rejected, so a redraw is extremely rare.

References

  1. MDN Web Docs, "Crypto: getRandomValues() method", accessed 2026-10-06, https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues — cryptographically strong values; PRNG seeded with enough entropy; 65,536-byte limit; integer typed arrays; insecure contexts and Web Workers; prefer generateKey() for keys.
  2. MDN Web Docs, "Math.random()", accessed 2026-10-06, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random — range and approximate uniformity; implementation selects the seed; not cryptographically secure; use Web Crypto.
  3. W3C, "Web Cryptography API" (Editor's Draft), accessed 2026-10-06, https://w3c.github.io/webcrypto/ — getRandomValues() algorithm, QuotaExceededError above 65,536 bytes, guidance to seed from OS entropy such as /dev/urandom; randomUUID(); use generateKey for keys.
  4. Yang Guo, V8 blog, "There's Math.random(), and then there's Math.random()", 17 December 2015, https://v8.dev/blog/math-random — V8 switched to xorshift128+; "still not cryptographically secure"; recommends getRandomValues.
  5. NIST, SP 800-90A Rev. 1, "Recommendation for Random Number Generation Using Deterministic Random Bit Generators", June 2015, https://csrc.nist.gov/pubs/sp/800/90/a/r1/final — DRBG mechanisms based on hash functions or block ciphers.
  6. D. Eastlake 3rd, J. Schiller, S. Crocker, RFC 4086 (BCP 106), "Randomness Requirements for Security", June 2005, https://www.rfc-editor.org/rfc/rfc4086 — traditional PRNG sequences are fully predictable if the initial state is known (sec. 6.1.3); strong seed, strong steps, do not reveal state (sec. 6.2).
  7. YourPasswordGenerator, published source random.ts and password.ts, site path /source/crypto/random.ts.txt and /source/crypto/password.ts.txt (repository: src/lib/crypto/random.ts, src/lib/crypto/password.ts) — rejection sampling over 32-bit draws, batching within the 65,536-byte limit, no Math.random fallback, whole-password redraw for required classes.