Common mistakes in token generation

A catalogue of failures in random token code, each one quoted from a generator that is live right now, with what it actually costs.

Updated 2026-09-05

Where this list comes from

Fifteen generators ranking on the first page of Google were read line by line. Five use Math.random() somewhere in generator logic. One uses a proper cryptographic source and then discards its uniformity with a modulo. Two are textbook-correct. Seven generate on a server, where the code cannot be read at all.

That distribution matters more than any individual bug. The convenient assumption is that online generators are uniformly bad, and they are not. The failures are specific, they cluster in a handful of places, and every one is easy to reproduce accidentally in your own code. Read the list as a checklist against your own token path rather than as a scoreboard.

Mistake one: Math.random() anywhere in the draw

V8's Math.random is xorshift128+ with a 128-bit state, seeded once per context. Recovering that state from a short run of outputs is published work, and once it is recovered every future value is known. It is a fine tool for jittering a retry, picking a test fixture or shuffling a carousel. It is never a source of secrets.

The clearest instance in the audit is onlinestringtools, whose entire random engine is one function: r[Math.floor(Math.random()*r.length)]. The same page tells the reader that a fifteen-character lowercase password gives "26^15 = 2^70 or 70 bits of complexity". The arithmetic is right and the claim is still wrong, because the formula assumes an unpredictable source and there is not one.

Browserling has the same engine across a whole tool family, including its password generator and its UUID version 4 generator, which builds the value with a Math.random-driven replace over the template string. RFC 9562 requires a cryptographic source for version 4 specifically.

A fair note, because it is the part people get wrong when repeating this: pinetools, codebeautify and textfixer also use Math.random, but all three use the correct multiply-and-floor mapping rather than a modulo, so their output distributions are sound. A 3.6 million draw chi-square on codebeautify's exact expression returned 38.9 on 35 degrees of freedom. Their problem is predictability alone, and accusing them of bias as well would be false.

The tell is the data type. If a secret is derived from a floating point number at any point in the pipeline, the source is wrong, no matter how carefully the float is turned into a character.

Mistake two: reducing a random word with a modulo

jam.dev does the hard part correctly and then throws it away in the last operation. Its generator reads, verbatim: let g=e=>{let s=new Uint32Array(1);return window.crypto.getRandomValues(s),s[0]%e}. The source is Web Crypto. The mapping is a bare modulo, which is only uniform when the alphabet size divides the draw range exactly.

In their specific case the damage is trivial. Their largest pool is 91 characters, and 2 to the 32 leaves a remainder of 74 when divided by 91, so the first 74 characters are over-represented by about 0.00000212 percent. That is real non-uniformity and it is cryptographically irrelevant. Their Fisher-Yates shuffle, for what it is worth, is correct.

The same mistake on a single byte is not irrelevant at all. 256 is 4 times 62 plus 8, so eight of the sixty-two symbols have five preimages and the rest have four. The most likely symbol is exactly 1.25 times more likely than the least. Shannon entropy barely moves, which is why the bug survives casual inspection, but min-entropy falls 0.276 bits per character, which is 4.418 bits over a sixteen-character string. An attacker searching most-likely-first faces a space about 21 times smaller than the tool claims.

The fix is rejection sampling: compute the largest multiple of n that fits in the draw range, discard anything at or above it, and take the modulo of what remains. Over 32-bit draws the rejection probability stays below one in a hundred million for every alphabet anyone uses, so exact uniformity is free.

What the mapping costs, by draw width
MappingAlphabetMost-likely to least-likely ratioVerdict
uint8 % n621.25Real. 4.418 bits of min-entropy over 16 characters.
uint32 % n621.000000014Measurable in theory, irrelevant in practice.
uint32 % n911.0000000212Same. This is jam.dev.
uint32 with rejectionany1Exactly uniform. Costs one redraw in roughly a billion.

The figure 1.0323 is quoted all over the internet as "the bias". It is 256 divided by 248, the expected number of draws per accepted output under rejection sampling. It is a performance number, not a bias number, and conflating the two is what a knowledgeable reviewer notices first.

Mistake three: counting entropy over one alphabet and sampling from another

randomkeygen ships genuinely correct 32-bit rejection sampling and throws if Web Crypto is missing rather than falling back. It has one real bug, and it is instructive because it is in the reporting layer rather than the generator.

Character selection indexes the raw custom character set the user typed. The entropy readout is computed over a deduplicated set of that same string. Give it "aab" and it samples the letter a two times in three, which is 0.918 bits per character, and then reports 1.000 bits per character. The number on screen describes a generator that was never run.

This is not one site being careless. Of the five audited generators with a custom character set field that were actually probed for duplicate handling, five got it wrong. gigacalculator, given nine a tokens and one b at length one over two thousand draws, produced the letter a 90.25 percent of the time with no warning. codebeautify given "AA" at length twenty silently reports zero bits. unit-conversion.info returned 2,013 a and 987 b over three thousand draws from "aab", which is exactly the 2:1 you would predict.

The rule is short: the number you display must be computed from the distribution you actually sample from. Normalise and deduplicate the alphabet before generating, then show the user what was removed.

Mistake four: forcing a required character into a fixed position

Almost every implementation of "must contain at least one digit and one symbol" works the same way: generate a string, then overwrite the first character with a digit and the second with a symbol. jam.dev does this in its GetMandatoryChars path, and it is the default approach in a great deal of application code.

The result is that position zero has a different marginal distribution from every other position. A per-position chi-square test finds it on the first run, and an attacker who knows which tool produced the value knows the layout too. Shuffling afterwards is the usual patch. It removes the positional pattern only if the shuffle is unbiased, which brings you to the next mistake, and even a perfect shuffle leaves the output non-uniform over the valid strings, because some of them can be produced in more ways than others.

The correct approach is whole-string rejection: generate the complete string, test it against every required class, discard and regenerate on failure. It is uniform over the set of valid strings by construction, and the entropy is exactly the unconstrained figure plus the logarithm of the acceptance rate. It is also cheap. At sixteen characters over sixty-two with the usual class set, the expected number of attempts is 1.064. At eight characters over ninety-four with four required classes it is 2.17. Cap the retry loop and validate up front that the length is at least the class count, so an impossible configuration produces an error rather than a hang.

Mistake five: a shuffle that is not a shuffle

The broken Fisher-Yates draws the swap partner uniformly from the whole range on every iteration instead of from the unvisited prefix. That gives n to the power n execution paths mapping onto n factorial outcomes, and n factorial does not divide n to the n for any n above 2, so by pigeonhole some permutations are strictly more likely than others. For three elements the outcome counts are 5, 5, 5, 4, 4, 4 out of 27, a 25 percent spread over something that should be flat.

The correct version draws the partner uniformly from index zero through the current index inclusive, and takes those indices from the same unbiased sampler used everywhere else. Reintroducing a modulo inside the shuffle undoes the work done outside it.

A current example, in case this reads as a textbook problem: the ULID reference implementation divided its random value by 0xff instead of 0x100, producing a value in the closed interval from zero to one rather than the half-open one. That made the first character of its alphabet about 29 percent more likely than the last. It was fixed in pull request 128, merged in November 2025 and released in 3.0.2.

Mistake six: putting the secret in a URL

random.org generates strings server-side from atmospheric noise hardware. Its form is a plain GET with no submit handler, so requesting values navigates the browser to a path carrying the parameters in the query string, and the server returns the generated values in the response body.

The generated value is in the response rather than the request, but the pattern generalises badly. Any secret that appears in a query string has been written into an access log, a proxy log, browser history, and the Referer header of whatever the user clicks next. None of those are places you control or can reliably purge.

The fairness note matters here more than anywhere else in this list. random.org has genuinely superior entropy: hardware sampling of atmospheric noise at roughly 12,000 bits per second, metered at a million bits per IP per day. Their form also works completely with JavaScript disabled, which most of the field cannot claim. The objection is to the transport, not the randomness. A value no third party has ever held has to be generated where you are.

Mistake seven: returning nothing instead of returning an error

jam.dev, with all four character-class boxes unchecked, computes s[0] % 0. In JavaScript that is NaN, the alphabet index is NaN, and the output is an empty string with no message. The impossible configuration produces silence.

The pattern repeats across the audit in worse forms. pinetools, given a non-numeric length, rewrites the field to the literal string "NaN" and emits nothing. codebeautify emits bare newlines for negative, empty or non-numeric lengths, and its generator file contains no validation branch of any kind. unit-conversion.info falls back to defaults for 0, -5 and abc, with an errors div that is never populated.

The worst case in the set is textfixer. Unchecking all three character-set boxes throws an uncaught TypeError, and the previous batch stays in the textarea, so the user sees a full box of strings and believes they are new. It also silently converts a request for 20,000 strings into 1,000.

An empty output box gets noticed. A stale one does not. If a configuration cannot be satisfied, say so, name the reason, and clear whatever was on screen.

The catalogue, with how to find each one in your own code
MistakeHow to detect itThe fix
Math.random in the drawGrep. There is no statistical test that finds it.crypto.getRandomValues, or the platform equivalent.
Modulo on a byteChi-square on character frequency, df = N - 1.Rejection sampling over 32-bit draws.
Entropy over a deduplicated setFeed it a duplicated alphabet and compare the readout to the observed frequencies.Normalise the alphabet before sampling and before counting.
Required class placed at a fixed indexPer-position chi-square, df = L * (N - 1).Whole-string rejection.
Naive shuffleEnumerate all permutations of 3 or 4 elements over many runs.Durstenfeld, partner drawn from [0, i].
Secret in a query stringRead the request line in your own access log.Generate locally, or POST over TLS and never log the body.
Silent empty or stale outputSubmit an impossible configuration and look at the screen.Explicit error, cleared output.

Questions

Is modulo bias actually exploitable?

It depends entirely on the draw width. Over a single byte and a 62-character alphabet it costs 0.276 bits of min-entropy per character, which is 4.418 bits over sixteen characters and shrinks an attacker's ordered search space by roughly 21 times. Over a 32-bit draw the same operation costs about three ten-millionths of a bit over the same string. The bug is the same; the magnitude is not, and treating them as equivalent is how this topic gets overstated.

How do I tell whether a generator I did not write is using Math.random?

Read the source, because no output test will tell you. Math.floor(Math.random() * n) produces a distribution that passes a chi-square test comfortably, which is the point: statistical uniformity and unpredictability are different properties. In a browser, open the JavaScript bundle and search for Math.random, then check whether the hits are in generator logic or in vendor code minting DOM ids. That distinction is where most public accusations go wrong.

What is wrong with generating a token and then replacing characters to satisfy a policy?

Post-hoc substitution collapses the distribution. Once you rewrite a character based on a rule, the positions you rewrote are no longer uniform and the entropy you calculated no longer describes the output. Both required-class rules and ambiguous-character removal have to happen before or around sampling: remove characters from the alphabet up front, and satisfy class rules by rejecting and regenerating whole strings.

Do these mistakes matter for something like a coupon code?

The predictability ones do, if the code is worth money. The bias ones usually do not. What does matter for short codes is collision, which is a separate problem: at four characters over a 62-character alphabet, duplicates in a batch of a few hundred are expected, not unlucky. Check for that explicitly rather than assuming randomness implies distinctness.

Is server-side generation a mistake in itself?

No. Seven of the fifteen audited generators are server-side and several of them use entirely legitimate cryptographic sources. The tradeoff is that the value crossed a network and existed on a machine you do not control, and you cannot verify the algorithm from outside. For a value you are about to paste into production, that is a reason to prefer local generation. It is not a reason to call the server-side tools broken.

Try it

Related reading