Randomness & Probability

How Random Number Generators Work: Pseudo-Random vs. True Random

Most random number generators on computers aren't random at all. A pseudo-random number generator (PRNG) starts from a value called a seed and uses a fixed formula to produce a long sequence of numbers that look random. If you use the same seed, you get the same sequence again. True random number generators work differently: they measure unpredictable physical events such as electrical noise.

Between those two sits a third kind, the cryptographically secure PRNG (CSPRNG). It is still a formula, but it's designed so that nobody can predict the next output, even after seeing earlier ones. Passwords, encryption keys, and session tokens need this kind.

Below, you'll step through a tiny generator by hand and see why Math.random() is the wrong tool for security. You'll also learn how to turn raw random bits into a number like "1 to 6" without quietly favoring some results.

How pseudo-random number generators work

A PRNG has three parts:

  • State: a number (or a few) held in memory. The seed is the starting state.
  • Transition function: a formula that turns the current state into the next state.
  • Output function: a way to turn the state into the number you see. Often this just returns the state or part of it.

The process is completely deterministic: the same seed always gives the same sequence. That makes PRNGs fast and repeatable, which is exactly what simulations, games, and tests need. If a bug shows up in a simulation, you can rerun it with the same seed and get the same "random" events.

A tiny linear congruential generator, step by step

The linear congruential generator (LCG) is one of the oldest PRNG designs. Its formula is:

X(next) = (a × X + c) mod m

"mod m" means "keep the remainder after dividing by m." Take a toy version with a = 5, c = 3, m = 16, and seed X₀ = 7:

  1. X₁ = (5 × 7 + 3) mod 16 = 38 mod 16 = 6
  2. X₂ = (5 × 6 + 3) mod 16 = 33 mod 16 = 1
  3. X₃ = (5 × 1 + 3) mod 16 = 8 mod 16 = 8
  4. X₄ = (5 × 8 + 3) mod 16 = 43 mod 16 = 11
  5. X₅ = (5 × 11 + 3) mod 16 = 58 mod 16 = 10
  6. X₆ = (5 × 10 + 3) mod 16 = 53 mod 16 = 5

Keep going and the full sequence is:

7, 6, 1, 8, 11, 10, 5, 12, 15, 14, 9, 0, 3, 2, 13, 4, then back to 7.

Every number from 0 to 15 shows up exactly once before the cycle repeats. The order looks fairly scrambled. But this toy example also shows the weaknesses of LCGs:

  • It repeats. After 16 steps you're back at the seed, and the same 16 numbers follow forever.
  • The low bits follow a pattern. Look at odd and even: 7 (odd), 6 (even), 1 (odd), 8 (even), and so on. The last bit simply alternates. Real LCGs with a power-of-two modulus have the same flaw in their lowest bits.
  • It's predictable. If you know the formula and see one output, you can compute every output after it.

Real LCGs use much larger numbers, such as m = 2³², but the structure is the same.

Why the period and seed matter

The period is how many numbers a generator produces before the sequence repeats. A generator can't have more distinct states than its state size allows. With 32 bits of state, the period is at most 2³² = 4,294,967,296. That sounds large, but a busy simulation can use that many numbers quickly. Modern general-purpose generators carry more state. The Mersenne Twister, which Python's random module uses, has a period of 2¹⁹⁹³⁷ − 1.

The seed matters just as much. A common shortcut is to seed with the current time. If the seed is a Unix timestamp in seconds, a whole day has only 86,400 possible seeds. Someone who knows roughly when your program started can try them all in moments and rebuild your entire "random" sequence.

Cryptographically secure generators and when you need them

A CSPRNG is still an algorithm. The difference is that someone who sees any amount of its output can't predict the next bit with meaningfully better than 50/50 odds, and can't work backward to earlier outputs. Two things make that possible:

  1. A secret, high-entropy seed. Entropy here means unpredictability. The operating system collects it from hard-to-guess events such as hardware noise and timing jitter.
  2. A one-way design. The generator is usually built from cryptographic parts such as hash functions, block ciphers, or stream ciphers, so its output reveals nothing useful about its internal state.

NIST SP 800-90A describes approved designs of this type, which it calls deterministic random bit generators (DRBGs). Companion documents SP 800-90B and SP 800-90C cover entropy sources and complete constructions.

You need a CSPRNG whenever someone could gain something by guessing the number:

  • Passwords, PINs, and password-reset codes
  • Encryption keys, nonces, and salts
  • Session IDs, API keys, and login tokens
  • Invitation links or file links that are "secret" only because they're hard to guess
  • Lottery-style draws and giveaways where people have a reason to cheat

Where to get one depends on your platform. Operating systems provide them (for example, getrandom() and /dev/urandom on Linux, and system APIs on Windows and macOS). Browsers expose one through crypto.getRandomValues(). Python has the secrets module, and Java has SecureRandom. Random bytes from these sources are often turned into text for URLs or tokens with an encoding such as Base64.

True random numbers from hardware noise

A true random number generator (TRNG), also called a hardware random number generator, measures a physical process that is unpredictable. Common sources include:

  • Thermal noise: tiny random voltage changes in resistors and other components
  • Oscillator jitter: small timing variations in free-running circuits on a chip
  • Quantum effects: such as photon behavior or radioactive decay, used in specialized devices

Raw physical measurements are rarely perfect. They can lean toward 1s or 0s, or drift with temperature. So hardware generators condition their output, running it through a processing step that removes bias. Many modern processors include an on-chip noise source of this kind.

In practice, operating systems seldom hand raw hardware randomness straight to programs. They mix hardware entropy with other sources and use it to seed a CSPRNG, which can then produce large amounts of output quickly. You get the unpredictability of physics plus the speed and reliability of a well-studied algorithm.

Pseudo-random vs. cryptographically secure vs. true random

Feature PRNG (e.g., LCG, Mersenne Twister) CSPRNG (OS or crypto library) TRNG (hardware noise)
Source of randomness Formula plus a seed Formula plus a secret, high-entropy seed Physical process
Repeatable with same seed Yes Possible in principle, but the seed is kept secret No
Predictable from past outputs Often, yes No (by design) No
Speed Very fast Fast Usually slower; often used only for seeding
Typical uses Games, simulations, procedural content, tests Passwords, keys, tokens, security-sensitive draws Seeding CSPRNGs, specialized hardware
Safe for security No Yes Yes, after conditioning

Why Math.random() is not for security

JavaScript's Math.random() returns a decimal number from 0 up to (but not including) 1. The language standard doesn't say which algorithm to use. Browser engines pick fast, non-cryptographic PRNGs because most uses are animations, games, and shuffling a list of tips. MDN's documentation for Math.random() states plainly that it does not provide cryptographically secure random numbers.

The risk is practical. For many fast PRNGs, an attacker who collects enough outputs can rebuild the internal state and predict every future value. Major browser engines currently use an algorithm called xorshift128+ for Math.random(), and its state can be recovered from a small number of outputs. Other languages have the same problem. For the 32-bit Mersenne Twister behind Python's random module, 624 consecutive outputs are enough to recover the full state. If your password-reset tokens come from a generator like these, anyone who requests a few tokens of their own may be able to predict someone else's.

The fix is easy. In the browser and in modern Node.js, use crypto.getRandomValues(), or crypto.randomUUID() for random identifiers. In Python, use secrets instead of random. The same rule holds in every language: the general-purpose "random" function is usually the fast one, not the secure one.

How to map random numbers to a range without modulo bias

Generators give you raw bits, such as a byte from 0 to 255 or a 32-bit integer. To simulate a die, you need 1 to 6. The obvious approach is (x mod 6) + 1, but that introduces modulo bias, which means some results come up more often than others.

Worked example: a die from one random byte

A byte has 256 possible values. 256 ÷ 6 = 42 with a remainder of 4, because 6 × 42 = 252.

  • Values 0–251 cover each remainder 0–5 exactly 42 times.
  • The 4 leftover values (252, 253, 254, 255) give remainders 0, 1, 2, and 3.

So remainders 0–3 occur 43 times each, while remainders 4 and 5 occur only 42 times:

  • Faces 1–4: 43/256 ≈ 16.80% each
  • Faces 5–6: 42/256 ≈ 16.41% each

Check: 4 × 43 + 2 × 42 = 172 + 84 = 256. A fair die gives each face 1/6 ≈ 16.67%. So faces 1–4 sit about 0.13 percentage points above fair (16.80 − 16.67), and faces 5–6 sit about 0.26 points below (16.67 − 16.41). That's a 0.39-point spread between the most and least likely faces. Measured as a relative difference instead, a face showing 1–4 is about 2.4% more likely than a face showing 5–6 (43 ÷ 42 ≈ 1.024). The difference between percentage points and percent matters when you report gaps like this. The bias is small, but over millions of rolls, or in a cryptographic setting, it is measurable.

The bias gets smaller as the raw range gets larger compared with your target range. With a 32-bit value and a range of 10, 2³² = 4,294,967,296 leaves a remainder of 6. Digits 0–5 then get 429,496,730 chances each and digits 6–9 get 429,496,729. That difference doesn't matter for a game. But it's still not exactly uniform, and with large target ranges the bias grows quickly.

The fix: rejection sampling

Rejection sampling means you throw away the leftover values and draw again:

  1. Let n be the size of your range (for 1–6, n = 6).
  2. Compute the limit, which is the largest multiple of n that fits in the raw range. For bytes, 256 − (256 mod 6) = 256 − 4 = 252.
  3. Draw a raw value x. If x ≥ limit, discard it and draw again.
  4. Otherwise, return min + (x mod n).

With bytes and n = 6, you reject 4 out of 256 values, so only about 1.6% of draws need a retry. Here's the same idea in JavaScript using a secure source. The input checks at the top make the function fail loudly instead of returning wrong numbers when it gets non-integers, a reversed range, or a range wider than 2³² values.

function secureRandomInt(min, max) {
  if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || max < min) {
    throw new RangeError('min and max must be integers with min <= max');
  }
  const n = max - min + 1;                 // size of the range
  if (n > 2 ** 32) {
    throw new RangeError('range must contain at most 2^32 values');
  }
  const limit = 2 ** 32 - (2 ** 32 % n);   // largest multiple of n that fits
  const buf = new Uint32Array(1);
  let x;
  do {
    crypto.getRandomValues(buf);
    x = buf[0];
  } while (x >= limit);
  return min + (x % n);
}

Many libraries already do this for you. Python's secrets.randbelow(n) and Java's SecureRandom.nextInt(bound) return unbiased integers. If your language has one of these, use it instead of writing your own.

A related pitfall is scaling a floating-point number, as in Math.floor(Math.random() * n). That's fine for games because the bias is tiny. It still isn't secure, though, because the generator underneath isn't secure.

Quick reference: which generator to use

  • Game mechanics, animations, procedural maps: a standard PRNG is fine and fast.
  • Simulations and tests you need to rerun: a seeded PRNG. Log the seed so you can reproduce results.
  • Passwords, keys, tokens, reset codes, secret links: a CSPRNG from your OS, browser, or language's security module. Never Math.random() or Python's random.
  • Giveaways or draws where fairness may be challenged: a CSPRNG, plus unbiased range mapping.
  • Seeding: never use the clock alone for anything security-related. Let the operating system supply the seed.
  • Picking a number in a range: use a library function that's documented as unbiased, or use rejection sampling. Avoid plain x mod n with small raw ranges.
  • Hardware randomness: you rarely need to access it directly. The OS already feeds it into its secure generator.

Frequently Asked Questions

Why do random numbers sometimes look streaky or not random?

Real randomness produces streaks and clusters more often than people expect. In 20 coin flips, for example, a run of four or more identical results is common. A sequence that never repeats or always alternates would actually be less random. People tend to judge randomness by how evenly spread the results look, and that's a poor test.

How do I get the same random results every time for testing?

Use a seeded pseudo-random generator, set the seed explicitly at the start of the run, and record it. Most general-purpose random libraries accept a seed. Secure generators are deliberately not reproducible this way, so keep them out of tests that need repeatable output, or swap in a seeded generator only in the test environment.

Is /dev/urandom safe for generating encryption keys?

On modern Linux systems, /dev/urandom and the getrandom() system call are generally considered appropriate for cryptographic keys once the system's random pool has been initialized during boot. By default, getrandom() waits for that initialization, which is one reason it's often preferred. Details can vary by operating system and kernel version, so check your platform's documentation for very early boot or embedded use.

Are UUIDs secure enough to use as secret tokens?

A version 4 UUID contains 122 random bits, which gives plenty of possibilities. Whether it's safe as a secret depends on how it was generated. Browser crypto.randomUUID() uses a secure source, but some libraries have used non-cryptographic generators. Other versions are built from timestamps and hardware identifiers (v1, v6), partly from timestamps (v7), or are deterministic hashes of a name (v3, v5), so they shouldn't be treated as secrets.

Can a computer ever generate truly random numbers?

Yes, if it has a hardware noise source that measures an unpredictable physical process, such as thermal noise or timing jitter. Many modern processors and security chips include one. Software running a formula can only produce pseudo-random output on its own. That output can still be unpredictable enough for security when it's seeded from real entropy.