Why a password from Math.random can look messy and still not be cryptographically secure

A page just printed a string with mixed case, digits, and symbols. That only describes the character set. It does not prove the next string is unpredictable. Do not trust the words “random” or “local” on the page. Open DevTools: the Generate path should call crypto.getRandomValues, and the password you just made should not show up in Network as business data.

Start with the conclusion: Math.random() is an ordinary pseudo-random number generator. MDN says it does not provide cryptographically secure random numbers and must not be used for anything related to security. Login passwords, one-time tokens, and keys need crypto.getRandomValues(). A messy-looking string, a Generate button that still works offline, and a lock in the address bar each prove a different boundary. None of them answers “which API did Generate call.”

Answer the search intent in one sentence

Math.random() returns a floating-point number in [0, 1) with an approximately uniform distribution. MDN is blunt: that value is not cryptographically secure; use Crypto.getRandomValues() instead. “Looks messy enough” describes the output’s character mix. It says nothing about whether an observer who sees a few outputs—or the page source—can recover the generator’s internal state and print the next candidates.

This note is for developers and ops who review an “online password generator,” and for anyone a colleague sent a Generate tab they want to inspect before they use it. It is not a how-to for the password generator, and it does not repeat the Network walkthrough for “did plaintext leave this tab.” What you should leave with is the layer that separates an ordinary PRNG from a CSPRNG, why a naive modulo can bias even a good API, and the exact path from the Generate click down the call stack.

Looks messy only means the characters may be mixed

Judging “random” by eye is cheap and usually wrong. aB3$kP9x and Tr0ub4dor&3 both look like passwords. The first may come from a wide charset. The second may be one memorable phrase with common substitutions. Looks answer “does this resemble a password,” not “can the next bit be computed.”

Even when the charset really includes upper case, lower case, digits, and symbols, a predictable index sequence still shrinks the job. An attacker who can rerun the same generator—or recover its state after a few observed outputs—enumerates leftover candidates instead of the full space. For a login password, unpredictability comes before “it has a special character.”

Length is still a separate question. The same CSPRNG at 6 characters and at 16 characters differs by orders of magnitude in brute-force cost. Shorter than 8 is a small space even when the API is the right one. FastPwd’s random mode allows 6–128 characters, defaults to 16, and flags shorter than 8 as weaker—that is a length warning, not a weaker algorithm. Keep the two claims apart: the API answers unpredictability; length answers search space.

What kind of random number Math.random actually is

ECMAScript specifies Math.random() as a Number greater than or equal to 0 and less than 1, drawn with an implementation-defined algorithm or strategy that is approximately uniform. The language spec does not require resistance to prediction, and it does not require the seed to come from the operating system’s entropy pool. An engine may pick a PRNG with excellent statistics and a reversible state.

Chrome’s V8 engine used MWC1616 until late 2015. V8’s own write-up records the limits: 64 bits of internal state, biased quality, and failures in the TestU01 suite. From V8 4.9.41 (shipping with Chrome 49) it switched to xorshift128+: 128 bits of state, period 2^128 − 1, and a pass on TestU01. Firefox and Safari later moved to the same family. Those are statistical upgrades, not a security upgrade.

The same V8 post draws the line in one sentence: xorshift128+ is much better than the old implementation and is still not cryptographically secure. Hashing, signatures, and encrypt/decrypt are not jobs for an ordinary PRNG; they belong on the Web Cryptography API’s window.crypto.getRandomValues. “Looks statistically messy” and “an observer cannot cheaply recover later outputs” are two different bars. A password needs the second.

GitHub’s CodeQL query Insecure randomness treats Math.random used to mint a password as a finding for the same reason: if an attacker can predict the value, they can log in as the new user. A generator that only calls Math.random() and maps onto a charset may pass a glance test and a crude frequency check, then fail the review that assumes someone read the source or intercepted a few outputs. Do not write “it used Math.random, but the charset is wide” as a pass.

What getRandomValues actually changes

crypto.getRandomValues(typedArray) fills an integer typed array—Uint8Array, Uint32Array, and the other integer views—with values that are random in the cryptographic sense. MDN is explicit about the implementation trade-off: for performance, browsers are not usually reading a hardware RNG on every bit. They seed a PRNG that is suitable for cryptographic use from an OS entropy source such as Unix /dev/urandom or the Windows system random interface. The algorithm may change across browsers. The design goal does not: published output should not let someone cheaply recover the internal state.

The two APIs do not live in the same specification. Math.random is ECMA-262. getRandomValues is Web Cryptography. A single call larger than 65,536 bytes throws QuotaExceededError; a 16-character password is nowhere near that cap. getRandomValues is also the only Crypto method that may run in an insecure context; key derivation and subtle.encrypt still require a secure context (HTTPS on a production page). “It still works on HTTP” is not a reason to drop TLS.

Split “this tab drew the random bytes” from “the server drew them and sent them back.” Even if the server used a high-quality CSPRNG, the password has already sat in the other side’s memory, logs, and that HTTP exchange—as plaintext or as something reversible. The lock in the address bar only encrypts the hop from this browser to the current TLS endpoint. It does not mean the endpoint cannot read the body. If the page claims “generated locally,” Generate should not POST the password out and echo it back.

What you saw What that can support What that cannot support
The output looks messy The charset may be wide The process is unpredictable
Source contains Math.random The page uses an ordinary PRNG somewhere Generate uses it for the password
Source contains getRandomValues The page imported a CSPRNG API This click used that API
Generate still works offline This click did not wait on a live minting API The source is not Math.random
Network search misses the password This observation did not upload it as business data The random API is a CSPRNG

Modulo bias: a CSPRNG can still be skewed

The second common mistake happens after the script already called getRandomValues. It takes a byte in 0–255 and writes charset[byte % charset.length]. If the charset length does not divide 256, the first few characters get an extra slice of the remainder and appear slightly more often. On a 16-character password the skew is usually small, but it shows the implementation did not finish “uniform.” A stranger charset, or mapping 8 bits onto a large word list, makes the bias easier to see.

Rejection sampling fixes it: compute the largest multiple of the charset size that still fits in 256 (or 2^32), discard any draw at or above that limit, then take modulo. FastPwd’s random characters and readable passphrases both call crypto.getRandomValues, draw a 32-bit integer with Uint32Array(1), set the limit to Math.floor(0x100000000 / max) * max, and loop when the draw sits in the tail. If getRandomValues is missing, generation is refused—there is no fallback to Math.random. When you review another site, keep reading through “draw an integer → discard the tail → map a character.” Finding the function name is not the end of the review.

Do not test with a real login password, a production API key, or an unredacted ticket. Generate a throwaway string and discard it. You are checking the Generate path and the traffic, not exposing a new secret a second time.

Verify now: source, console, Network

A slogan is not evidence. Prepare a marker that will never appear in real traffic: set length to 16, generate once, and treat the full string as a canary. The three steps below finish in this tab. No account is required.

  1. Open DevTools Sources, or use View Page Source. Search separately for Math.random and getRandomValues. Note the files and function names. Do not decide yet.
  2. Read down from the Generate button’s click handler until characters are concatenated into the result. That path should call crypto.getRandomValues. If you only see Math.random(), Date.now(), or a fixed seed, this generation is not a CSPRNG.
  3. Switch to Network, enable Preserve log, and leave the filter on All. Generate once more. Paste the full string into the search box. The request line, query string, request body, and analytics payloads should not contain that password.

The console only proves the API exists

In Console, typeof crypto.getRandomValues === "function" returning true only means this document’s browser exposes the method. It does not mean the page script called it. Running one line of getRandomValues yourself and then treating the page’s password as “already checked” is two unrelated snippets.

Offline only proves nothing was minted remotely

Let the page finish loading. Then set Network to Offline, or drop the system network, and generate again. If a result still appears, this click did not depend on a live API. That is a strong signal for “generated in this tab.” Math.random also needs no network. Offline is a pass you still have to pair with the source path and the canary search.

Bundled scripts often crush names to a single letter. If getRandomValues does not appear, search crypto and Uint32Array, and watch whether Generate triggers a new business POST. Minification is not a waiver. It only means the search takes one extra hop.

Three ways people over-read “random”

First, treating the address-bar lock as proof the generator is a CSPRNG. HTTPS / TLS protects the hop from this browser to the current TLS endpoint. A server that receives a password you POSTed still shows a lock. The lock does not answer Math.random versus getRandomValues.

Second, treating “the bundle mentions getRandomValues” as proof Generate used a CSPRNG. The same file may use Web Crypto for file encryption, a burn link, or a demo, while the password path still calls Math.random. The reverse is also true: Math.random in an ad or an animation does not disprove the password path. The click stack is the evidence.

Third, treating “it was not uploaded” as “it is unpredictable.” A clean Network panel only means that, in this observation, the password did not leave the tab as an HTTP business field. An extension can still read the DOM. The OS clipboard history can still keep the string you copied. Those are different boundaries. Random quality, whether it uploaded, and whether a copy still sits somewhere after you leave, are three separate questions.

FAQ

If a generated password looks messy, can I use it as a login password?

Not from looks alone. Mixed characters only suggest the charset may be wide. They do not prove the process is unpredictable. MDN says Math.random must not be used for security. Also check whether the length is enough, whether you reused the string on other sites, and whether Generate calls crypto.getRandomValues.

If the page still generates a password offline, is the randomness safe?

That only proves this click did not wait on a live API and the server did not mint the string for you. Math.random also runs offline. After Offline succeeds, the source path should still show getRandomValues, and Network should not contain the password you just made.

The page source has both Math.random and getRandomValues. Can I still use it?

It depends which function the Generate button calls. Animations and shuffle order may use Math.random. Passwords, tokens, and keys must use getRandomValues. Open Sources and read down from the click handler. Do not stop at how many times each name appears in the bundle.

Which random API does FastPwd use, and is the password uploaded?

Random characters and readable passphrases both call crypto.getRandomValues and map a 32-bit integer onto the charset with rejection sampling, so the modulo tail is discarded. If the API is missing, generation is refused. Length is 6–128 characters, default 16; shorter than 8 is flagged as weaker. The result is not uploaded by default. Ready to use—no account.

Walk the same throwaway password through a ready-to-use tool

If you want a page that writes the random-number boundary in the open, open FastPwd’s password generator. No registration. Leave random mode at the default 16 characters and generate a throwaway string. Repeat section five: in Sources, read from Generate down to getRandomValues and the rejection-sampling loop; in Network, search the full string so the request line and body miss.

After that, if you also want to know whether the string resembles a widely abused password, take a different throwaway into Password Audit and match it against the public high-frequency list. The password you check is not uploaded. A miss does not prove it never appeared on the web; a hit only means it is too common. That is the weak-password list’s boundary, not the CSPRNG’s. Do not paste a password you are about to enable into any online checker.

Before the next unfamiliar generator tab, keep three facts. Looks messy does not prove unpredictable. Math.random can pass statistical suites and still not be a CSPRNG. Seeing getRandomValues in the bundle is not enough: you need the path you clicked, and a canary that the password did not leave as business data. Observations you can repeat belong on your own review checklist.

Further reading

“Local encryption, nothing uploaded” is not proof either—search Network with a canary in How to check if browser encryption never uploaded plaintext. A local weak-password list can catch common passwords and is not a full-web lookup; see What a local leaked-password list can prove, and why it is not a full-web lookup. If you later hand the password to a colleague once, why the part after # stays out of HTTP by default is in Why a URL hash can hold a key, and when that protection fails.