Applied Cryptography for Developers Module 3 – Randomness and secret values
Secure random numbers in browsers, Node and Python
Make keys, salts, nonces and tokens with a CSPRNG in browsers, Node and Python, respect the 65,536-byte limit, and keep seeds out of production code.
What you will learn
- Generate keys, salts, nonces and tokens with a CSPRNG in the browser, Node and Python
- Explain why Math.random and Python's random module must not produce secrets
- Handle the 65,536-byte limit of getRandomValues and choose the right call for each runtime
- Write tests that inject a fixed random source while production code takes no seed
Before you start
What you’ll learn · Before you start
What you will learn
- Generate keys, salts, nonces and tokens with a CSPRNG in the browser, Node and Python
- Explain why Math.random and Python's random module must not produce secrets
- Handle the 65,536-byte limit of getRandomValues and choose the right call for each runtime
- Write tests that inject a fixed random source while production code takes no seed
Before you start
On this page
A password-reset token made with Python’s random module and seeded with the clock looks like 32 random hex digits,
yet a test that tries every second of the last hour rebuilds it in 3,601 guesses. Keys, salts, nonces and tokens need
values that nobody can predict, and every platform ships a generator for exactly that job, next to a faster one that
must never make a secret.
This lesson shows where secure random bytes come from, how to get them in browsers, Node and Python, what the
65,536-byte limit of crypto.getRandomValues means for your code, how many bytes a secret needs, and how to test code
that uses randomness without ever putting a seed into production. A two-step code lab at the end builds a token
function and a buffer filler.
Random-looking output is not enough for secrets
A pseudo-random number generator computes every output from a hidden state with a fixed formula, starting from a
seed (of a generator): The starting value of a pseudo-random number generator. The same seed always gives the same sequence, so a seed that can be guessed, such as the clock, makes every output guessable.. Python’s random module is one: its documentation calls the Mersenne Twister at its
core completely deterministic and completely unsuitable for cryptographic purposes, and warns that its generators should
not be used for security purposes. MDN says not to use Math.random() for anything related to security and points to
crypto.getRandomValues() instead. Both are
built for simulations and games, where speed, good statistics and a repeatable run matter.
The program below makes the classic mistake on purpose. It seeds random.Random with the time a reset token is
issued, then runs the check a test suite should run: can the token be rebuilt from any second of the hour before it?
It runs the same check on a token from secrets.token_hex(16). Run it, then change ISSUED_AT and run it again:
"""A reset token seeded with the clock, and the check that catches it: rebuild the token from every second of an hour."""
import random
import secrets
ISSUED_AT = 1_700_000_000 # a clock reading in seconds, fixed so that the run repeats
def weak_token(clock):
"""The bug: a generator for simulations, seeded with the time the token was issued."""
rng = random.Random(clock)
return rng.getrandbits(128).to_bytes(16, "big").hex()
def rebuilt_from(token, seeds):
"""The check: how many of these seeds give exactly this token?"""
return sum(1 for seed in seeds if weak_token(seed) == token)
hour = range(ISSUED_AT - 3600, ISSUED_AT + 1) # every second of the hour before the token was issued
weak = weak_token(ISSUED_AT)
ones = bin(int(weak, 16)).count("1")
print(f"weak token: {weak}")
print(f" 1-bits: {ones} of 128, so it looks random")
print(f" seeds of the last hour that rebuild it: {rebuilt_from(weak, hour)} of {len(hour):,}")
strong = secrets.token_hex(16)
print(f"strong token: {len(strong)} hex characters from secrets.token_hex(16)")
print(f" seeds of the last hour that rebuild it: {rebuilt_from(strong, hour)} of {len(hour):,}")
# Same seed, same sequence: right for a simulation you want to repeat, wrong for anything secret.
a, b = random.Random(42), random.Random(42)
same = all(a.random() == b.random() for _ in range(1000))
print(f"two generators seeded with 42 agree on 1,000 draws: {same}") Output
weak token: 6b4b2d0c310f9fcb579cfc715210160b 1-bits: 62 of 128, so it looks random seeds of the last hour that rebuild it: 1 of 3,601 strong token: 32 hex characters from secrets.token_hex(16) seeds of the last hour that rebuild it: 0 of 3,601 two generators seeded with 42 agree on 1,000 draws: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 predictable.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The weak token has 62 one-bits out of 128, as a random value should, and nothing about its digits gives it away. One of
the 3,601 seeds rebuilds it exactly, because the seed was the only unknown, and a clock reading is easy to narrow down.
The secrets token survives the same check: there is no seed to find. The last line is the other side of the coin:
two generators seeded with 42 agree on all 1,000 draws, which is exactly what a simulation you want to repeat needs and
exactly what a secret must never allow.
Seeding with the time or a process ID is common enough to have its own entry in MITRE’s weakness list, CWE-337. One of its examples is a famous case: a change made only in Debian’s OpenSSL package left the generator predictable, so SSH, OpenVPN, DNSSEC and certificate keys made on those systems could be guessed (Debian advisory DSA-1571, CVE-2008-0166). CWE-337 notes that the generator had been left seeded with little more than the process ID.
A better seed does not make Math.random or random safe
Wrong: random.seed(os.urandom(32)), then random.getrandbits(128) for a token. The seed is now unpredictable, but
the generator is still a fast formula over its state, designed for statistics rather than against someone working
backwards from outputs it has already shown. Right: take secrets straight from secrets or crypto.getRandomValues,
which never let you choose a seed at all.
Secure bytes come from the operating system
A CSPRNG: A cryptographically secure pseudo-random number generator: no practical computation on the bits it has given predicts its next bit noticeably better than a coin toss. Operating systems run one, seeded from an entropy source. (CSPRNG) makes a different promise: no practical computation on the bits it has already given predicts its next bit noticeably better than a coin toss, which is how the Handbook of Applied Cryptography defines one. You do not build one, and you do not seed one. The operating system runs one for every program, and each language reaches it through a small API, directly or through a library generator that the system seeds. Step through the path the bytes take, from the hardware to your key:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Noise nobody can predict. Every secure generator starts from physical events whose exact timing or value no program can know in advance. NIST SP 800-90B calls the part that observes them a noise source.
- An entropy source. The entropy source turns that noise into raw bits and tests its own health while it runs. Its bits are unpredictable but slow to collect and may be biased, so programs never read them directly.
- A deterministic generator. The system feeds those bits as a seed into a deterministic random bit generator, a DRBG of the kind SP 800-90A specifies: Hash_DRBG, HMAC_DRBG or CTR_DRBG. It stretches the seed into as many bytes as programs ask for and reseeds itself.
- The only door. Programs ask through one call: getrandom on Linux, getentropy on macOS and OpenBSD, BCryptGenRandom on Windows. Right after boot, getrandom waits until the kernel has collected 128 bits of entropy.
- Your language wraps it. crypto.getRandomValues in browsers, workers and Node, crypto.randomBytes in Node, secrets and os.urandom in Python. Each draws on a generator that the system seeds, and none of them asks you for a seed.
- The bytes you keep. Sixteen bytes give 128 bits, the floor ASVS 5.0 sets for anything that must not be guessed, and 32 bytes give 256 bits, the default size of a Python secrets token. Encode them as hex or Base64url to store or send them.
NIST describes each layer in its own standard: SP 800-90B covers the entropy source: The part of a random bit generator that turns unpredictable physical events into raw bits and tests its own health, as NIST SP 800-90B describes. Its output seeds a DRBG.
that measure unpredictable physical events, SP 800-90A specifies the DRBG: A deterministic random bit generator, as NIST SP 800-90A specifies: Hash_DRBG, HMAC_DRBG or CTR_DRBG. It stretches a secret seed from an entropy source into as many random bytes as needed and is reseeded over time. that stretch their output (Hash_DRBG, HMAC_DRBG and CTR_DRBG; a fourth, Dual_EC_DRBG, was removed from
Rev. 1), and SP 800-90C says how to combine the two into a complete generator. OWASP’s ASVS 5.0 lists the same pieces
in its Appendix C, from /dev/urandom and getentropy() to the three DRBGs, as the generators it accepts.
The one moment a secure generator can make you wait is right after the computer starts, before it has collected
enough entropy. Python’s documentation says that on Linux os.urandom blocks until the kernel has gathered 128 bits,
and Node’s says crypto.randomBytes() does not complete until there is sufficient entropy, which should normally never
take longer than a few milliseconds. After that, none of them runs out. RFC 4086, the IETF’s guide to randomness for
security, explains why the home-made alternatives fail: values read from the clock carry far fewer unpredictable bits
than they appear to, and a generator seeded from them leaves an attacker only a few values to try.
Key idea
Ask the operating system: every call in this lesson draws on a generator that the system seeds and reseeds itself, and none of them needs or accepts a seed from you.
Checkpoint
Check: Random-looking is not unpredictable · The operating system's generator
In browsers and Node: getRandomValues, 65,536 bytes at a time
crypto.getRandomValues(array) is the call that works everywhere JavaScript runs: in pages, in workers and in Node,
where globalThis.crypto is the same Web Crypto object. The Web Crypto specification makes its steps exact: refuse
any array that is not an integer typed array with a TypeMismatchError, refuse more than 65,536 bytes with a
QuotaExceededError, then fill the array with cryptographically secure random bytes and return it. The limit is a
limit per call, so a larger buffer is filled one slice at a time, and a subarray view lets each call write into the
right part of the buffer without copying anything.
This program asks for a 32-byte key, tries the three sizes and types that sit on either side of the rules, fills a
mebibyte in slices, then uses node:crypto, which exists only in Node:
// Secure random bytes in JavaScript: crypto.getRandomValues (browsers, workers and Node) and node:crypto (Node only).
import { randomBytes, randomUUID } from 'node:crypto';
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
const base64url = (bytes) => Buffer.from(bytes).toString('base64url'); // Node's Buffer; browsers need a helper
const key = crypto.getRandomValues(new Uint8Array(32));
console.log(`32-byte key: ${hex(key)}`);
console.log(`as Base64url: ${base64url(key)} (${base64url(key).length} characters)`);
const requests = [
['65,536 bytes', () => new Uint8Array(65_536)],
['65,537 bytes', () => new Uint8Array(65_537)],
['a Float64Array', () => new Float64Array(4)],
];
for (const [what, make] of requests) {
try {
crypto.getRandomValues(make());
console.log(`getRandomValues(${what}): filled`);
} catch (e) {
console.log(`getRandomValues(${what}): ${e.name}`);
}
}
// More than 65,536 bytes: fill the buffer one slice at a time.
function randomBuffer(n) {
const out = new Uint8Array(n);
let calls = 0;
for (let i = 0; i < n; i += 65_536) {
crypto.getRandomValues(out.subarray(i, i + 65_536));
calls++;
}
return { out, calls };
}
const { out, calls } = randomBuffer(1 << 20);
console.log(`1 MiB in ${calls} calls; byte values seen: ${new Set(out).size} of 256`);
// node:crypto has no such limit (Node only).
console.log(`randomBytes(1 MiB): ${randomBytes(1 << 20).length.toLocaleString('en')} bytes in one call`);
console.log(`randomUUID(): version ${randomUUID()[14]} UUID`); Output
32-byte key: 6e80625e4b42a52b344f6817953d2c306d10869a70e624db963872c0577d4e64 as Base64url: boBiXktCpSs0T2gXlT0sMG0Qhppw5iTbljhywFd9TmQ (43 characters) getRandomValues(65,536 bytes): filled getRandomValues(65,537 bytes): QuotaExceededError getRandomValues(a Float64Array): TypeMismatchError 1 MiB in 16 calls; byte values seen: 256 of 256 randomBytes(1 MiB): 1,048,576 bytes in one call randomUUID(): version 4 UUID
This output changes from run to run: the key and its Base64url form are new random bytes on every run
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node random_bytes.mjs
The first two lines change on every run: that is the point. Exactly 65,536 bytes are filled, one byte more raises
QuotaExceededError, and a Float64Array raises TypeMismatchError, because the call fills integer typed arrays
only. The mebibyte took 16 calls of 65,536 bytes, and all 256 byte values turned up in it.
In Node, node:crypto adds three calls with no such small limit: randomBytes(n) returns a Buffer, randomUUID() a
version 4 UUID, and randomInt(min, max) an integer that avoids modulo bias, which the next lesson explains. Node’s
documentation calls the getRandomValues export of node:crypto a convenient alias that does not follow the Web
Crypto specification exactly, and suggests crypto.webcrypto.getRandomValues() for code that must also run in
browsers; the global crypto object is that webcrypto object. In a browser, crypto.randomUUID() exists only on
secure pages, while getRandomValues works on any page.
Checkpoint
Check: getRandomValues and its limits
In Python: secrets, os.urandom and how many bytes
Python’s secrets module is the high-level door to the operating system’s generator. Its documentation puts it first
for anything secret and leaves random to modelling and simulation. It gives tokens as bytes, hex or URL-safe Base64
text, and randbelow, randbits and choice for numbers and picks, all from the same source as os.urandom. Run
the program to see the sizes it chooses when you give none:
"""Python's secrets module: tokens of the default size and of a size you choose, and numbers in a range."""
import os
import secrets
print(f"secrets.DEFAULT_ENTROPY: {secrets.DEFAULT_ENTROPY} bytes")
print(f"{'length of':<16}{'default':<10}16 bytes")
for name in ["token_bytes", "token_hex", "token_urlsafe"]:
make = getattr(secrets, name)
print(f"{name + '()':<16}{len(make()):<10}{len(make(16))}")
print(f"os.urandom(32): {len(os.urandom(32))} bytes")
rolls = [secrets.randbelow(6) + 1 for _ in range(10_000)]
print(f"10,000 dice rolls with randbelow(6) + 1, every face from 1 to 6 seen: {set(rolls) == set(range(1, 7))}")
print(f"secrets.choice('ACGT') is one of the four letters: {secrets.choice('ACGT') in 'ACGT'}") Output
secrets.DEFAULT_ENTROPY: 32 bytes
length of default 16 bytes
token_bytes() 32 16
token_hex() 64 32
token_urlsafe() 43 22
os.urandom(32): 32 bytes
10,000 dice rolls with randbelow(6) + 1, every face from 1 to 6 seen: True
secrets.choice('ACGT') is one of the four letters: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tokens.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With no argument the token functions use secrets.DEFAULT_ENTROPY, 32 bytes here, which the documentation calls
enough for the typical uses of the module while warning that the right amount grows as computers get faster and that
the exact default may change. So pass the size whenever it matters: token_bytes(16) is 16 bytes, token_hex(16) 32
characters, and token_urlsafe(16) 22, about 1.3 characters a byte. How many bytes a value needs follows from its job:
| Value | Bytes | Why that many |
|---|---|---|
| Session ID, reset link, API token | 16 or more | ASVS 5.0 requirement 11.5.1: a CSPRNG and at least 128 bits for anything that must not be guessed |
| AES-128 key | 16 | the cipher’s key is 128 bits |
| AES-256 key | 32 | the cipher’s key is 256 bits |
| Salt for password hashing | 16 | Python’s hashlib documentation suggests about 16 or more bytes from a proper source |
| AES-GCM nonce | 12 | the 96-bit nonce of the security-levels lesson, with its birthday limit |
| A token where you set no size | 32 | secrets.DEFAULT_ENTROPY |
ASVS adds that UUIDs do not meet its 128-bit rule, which the next lesson takes apart. The choice of call depends on where your code runs and what it needs. Answer the three questions in the table below, which marks what fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Call | Safe for secrets | Size per call | What it returns | Can be seeded |
|---|---|---|---|---|
| crypto.getRandomValues Works in: browsers, workers, Node | Good fills from a cryptographically secure generator | Mixed up to 65,536 bytes; fill larger buffers in slices | Good your integer typed array, filled | Good no: the system seeds it |
| crypto.randomBytes Works in: Node (node:crypto) | Good waits for entropy right after boot, then answers at once | Good 1 MiB in one call in the lesson's run | Good a Buffer of the size you ask for | Good no: the system seeds it |
| crypto.randomUUID Works in: secure pages, workers, Node | Mixed 122 random bits, but ASVS says UUIDs miss its 128-bit rule | Mixed always one UUID | Mixed a version 4 UUID string, not raw bytes | Good no: the system seeds it |
| secrets.token_bytes Works in: Python | Good the module Python names for secrets | Good any size; 32 bytes when you give none | Good bytes, hex or URL-safe text | Good no: the system seeds it |
| os.urandom Works in: Python | Good reads the operating system's generator | Good any size | Good bytes | Good no: the system seeds it |
| Math.random Works in: browsers, Node | Poor MDN: not for anything related to security | Mixed one number per call | Poor a float from 0 up to 1 | Mixed the engine picks the seed, and you cannot see or set it |
| random.random Works in: Python | Poor Mersenne Twister: unsuitable for cryptographic purposes | Mixed one number per call | Poor a float from 0 up to 1 | Poor random.seed(42) repeats every value, by design |
How the answers decide
- “Must the value stay secret, or decide anything about security?” Yes: rules out Math.random and random.random. Both are fast generators for simulations: MDN rules Math.random out for security, and Python calls its Mersenne Twister unsuitable for cryptography.
- “Must the value stay secret, or decide anything about security?” No, it only has to look random: adds a caveat to Math.random and random.random. Fine for shuffling a quiz or running a simulation. Switch to a secure call the moment the value grants, proves or protects anything.
- “Where does the code run?” In a browser or a worker: rules out crypto.randomBytes, secrets.token_bytes, os.urandom and random.random. node:crypto and Python modules do not exist in a browser. crypto.getRandomValues works on every page; crypto.randomUUID on secure pages.
- “Where does the code run?” In Node: rules out secrets.token_bytes, os.urandom and random.random. Python modules are not available in Node: use getRandomValues, randomBytes or randomUUID.
- “Where does the code run?” In Python: rules out crypto.getRandomValues, crypto.randomBytes, crypto.randomUUID and Math.random. These are JavaScript calls. Python has secrets and os.urandom.
- “What do you need?” Bytes for a key, a salt or a nonce: rules out crypto.randomUUID. randomUUID returns a formatted string in which 6 bits are fixed, not the raw 16 or 32 bytes a key or a salt needs.
- “What do you need?” A token as text: adds a caveat to crypto.getRandomValues, crypto.randomBytes and os.urandom. They return bytes: encode them as hex or Base64url, or call secrets.token_urlsafe in Python.
- “What do you need?” A random identifier and “Must the value stay secret, or decide anything about security?” Yes: rules out crypto.randomUUID. An identifier that grants access is a secret token, and ASVS 5.0 requirement 11.5.1 says UUIDs do not meet its rule: use 16 or more random bytes.
- “What do you need?” A random identifier and “Must the value stay secret, or decide anything about security?” No, it only has to look random: adds a caveat to crypto.getRandomValues, crypto.randomBytes, secrets.token_bytes and os.urandom. They work, but randomUUID gives an identifier in the standard format in one call, which databases and logs already know.
In short: crypto.getRandomValues in browsers and workers, crypto.randomBytes or the same getRandomValues in Node,
and secrets in Python, with Math.random and random kept for shuffling a quiz or running a simulation.
Checkpoint
Check: secrets, os.urandom and sizes
Seeds belong in tests, never in the code that makes secrets
Tests want the opposite of security: the same output on every run, so that an expected token can be written into the
test. The tempting fix is a seed parameter on the function that makes tokens, or a random.seed(1) in a module that
the tests and the production code share. Both put a way to predict secrets into the code that ships. The safe design
passes the random source in as a parameter: production code uses the system generator as the default, and only tests
hand in a fixed source. This function takes a source the way crypto.getRandomValues fills a buffer:
// A token function that takes its random source as a parameter: the operating system's generator in production,
// a fixed source in tests. Production code never sees a seed.
const systemSource = (bytes) => crypto.getRandomValues(bytes);
export function issueToken(source = systemSource) {
const bytes = new Uint8Array(16); // 128 bits
source(bytes);
return Buffer.from(bytes).toString('base64url');
}
// A test source: the bytes 0, 1, 2, 3 ..., so a test knows every token in advance.
function countingSource() {
let next = 0;
return (bytes) => {
for (let i = 0; i < bytes.length; i++) bytes[i] = next++ & 0xff;
return bytes;
};
}
const first = issueToken(countingSource());
console.log(`test source: ${first}`);
console.log(`a second test gets the same token: ${issueToken(countingSource()) === first}`);
const a = issueToken();
const b = issueToken();
console.log(`system source: ${a.length} characters; two tokens differ: ${a !== b}`); Output
test source: AAECAwQFBgcICQoLDA0ODw a second test gets the same token: true system source: 22 characters; two tokens differ: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node injected_rng.mjs
The counting test source gives the token AAECAwQFBgcICQoLDA0ODw, the bytes 0 to 15 in Base64url, on every run, so a
test can assert it exactly. With no argument the function uses the system generator, and two calls give two different
22-character tokens. Nothing in the function reads a seed, an environment variable or a debug flag, so there is no
setting that could make production tokens predictable.
A seed parameter on production code
Wrong: def issue_token(seed=None): rng = random.Random(seed), which a test calls with seed=1. Anyone who learns
that a deployment passes a seed, or that one code path forgets the default, can rebuild its tokens. Right:
def issue_token(source=secrets.token_bytes), with tests passing lambda n: bytes(range(n)), so the production path
never accepts a seed.
For simulations, games and property-based tests, a seeded random.Random(seed) is the right tool, kept in its own
object rather than in the module’s shared state, and never used for a value that guards anything.
Key idea
Pass randomness in, do not seed it: a parameter that defaults to the system generator lets tests use a fixed source while the code that ships has no seed to leak.
Checkpoint
Check: Seeds only in tests
Practise: a token function and a buffer filler
Two exercises, each with sample tests that run on this page, build the two pieces of random-value code that
applications need most. The first makes a token with at least the entropy you ask for, from an injected source, and
its starter code rounds the length down. The second fills a large buffer from a source that refuses more than 65,536
bytes in one call, the way crypto.getRandomValues does:
Code lab · 2 steps
Build safe random-value helpers
Write a token function that reaches the entropy you ask for from an injected source, then fill large buffers within the 65,536-byte limit.
- A token with at least the entropy you ask for To do
- Fill a large buffer 65,536 bytes at a time To do
Exercise · Easy · Python, JavaScript
A token with at least the entropy you ask for
Write random_token(bits, alphabet, source) in random_token.py (JavaScript: randomToken(bits, alphabet, source) in random_token.mjs). It returns a string of characters from alphabet that carries at least bits bits of entropy:
- The alphabet has 2, 4, 8, 16, 32, 64, 128 or 256 distinct characters, so each character carries log₂ of that many bits: 4 for hex, 5 for Base32, 6 for Base64url. Use as few characters as reach
bits, rounding up: 128 bits in Base64url take 22 characters, because 21 carry only 126. - The random bytes come from
source, one byte for each character, and the byte's low bits pick the character. In Pythonsource(n)returnsnbytes; in JavaScriptsource(bytes)fills theUint8Arrayit is given, ascrypto.getRandomValuesdoes. Production code passes the system generator; the sample tests pass a counting source, so that every token is known in advance. - Raise
ValueError(JavaScript: throw anError) for any other alphabet size, which needs the rejection sampling of the next lesson, for an alphabet that repeats a character, and forbitsbelow 1.
The starter code rounds the length down and checks nothing.
Python · Starter code · random_token.py
def random_token(bits, alphabet, source):
"""A token of characters from alphabet that carries at least `bits` bits of entropy.
source(n) returns n random bytes: secrets.token_bytes in production, a fixed source in tests.
"""
size = len(alphabet)
per_character = size.bit_length() - 1 # 4 for hex, 6 for Base64url
length = bits // per_character # rounds down: too short whenever per_character does not divide bits
return "".join(alphabet[b % size] for b in source(length)) The sample tests · test_random_token.py
from random_token import random_token
HEX = "0123456789abcdef"
BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
def counting(n):
"""A test source: the bytes 0, 1, 2 ... (not random, so every test knows the token in advance)."""
return bytes(i % 256 for i in range(n))
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_hex_lengths():
"""128 and 256 bits in hex take 32 and 64 characters"""
assert len(random_token(128, HEX, counting)) == 32
assert len(random_token(256, HEX, counting)) == 64
def test_base64url_rounds_up():
"""128 bits in Base64url take 22 characters: 21 carry only 126 bits"""
assert len(random_token(128, BASE64URL, counting)) == 22
assert len(random_token(256, BASE64URL, counting)) == 43
def test_base32():
"""130 bits in Base32 take 26 characters, and 131 take 27"""
assert len(random_token(130, BASE32, counting)) == 26
assert len(random_token(131, BASE32, counting)) == 27
def test_each_byte_picks_one_character():
"""byte 0 picks the first character, byte 1 the second, and so on"""
assert random_token(64, HEX, counting) == "0123456789abcdef"
assert random_token(30, BASE64URL, counting) == "ABCDE"
assert random_token(5, "01", lambda n: bytes([2, 3, 4, 5, 255])) == "01011"
def test_one_call_for_the_whole_token():
"""the source is asked once, for exactly one byte per character"""
asked = []
def recording(n):
asked.append(n)
return counting(n)
random_token(128, BASE64URL, recording)
assert asked == [22]
def test_refuses_what_it_cannot_do_safely():
"""alphabets of 10, 62, 1 or 512 characters, a repeated character, and bits below 1"""
wide = "".join(chr(0x100 + i) for i in range(512)) # 512 distinct characters: more than a byte can pick
cases = [(128, "0123456789"), (128, BASE64URL[:62]), (128, "a"), (128, wide), (128, "aabbccdd"), (0, HEX), (-8, HEX)]
for bits, alphabet in cases:
assert raises_value_error(lambda: random_token(bits, alphabet, counting)), f"{bits} bits in {alphabet[:12]!r}" JavaScript · Starter code · random_token.mjs
/**
* A token of characters from alphabet that carries at least `bits` bits of entropy.
* source(bytes) fills a Uint8Array: crypto.getRandomValues in production, a fixed source in tests.
*/
export function randomToken(bits, alphabet, source) {
const size = alphabet.length;
const perCharacter = Math.log2(size); // 4 for hex, 6 for Base64url
const bytes = new Uint8Array(Math.floor(bits / perCharacter)); // rounds down: too short
source(bytes);
return Array.from(bytes, (b) => alphabet[b % size]).join('');
} The sample tests · random_token.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { randomToken } from './random_token.mjs';
const HEX = '0123456789abcdef';
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
// A test source: fills with 0, 1, 2 ... (not random, so every test knows the token in advance).
const counting = (bytes) => {
for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256;
return bytes;
};
test('128 and 256 bits in hex take 32 and 64 characters', () => {
assert.equal(randomToken(128, HEX, counting).length, 32);
assert.equal(randomToken(256, HEX, counting).length, 64);
});
test('128 bits in Base64url take 22 characters: 21 carry only 126 bits', () => {
assert.equal(randomToken(128, BASE64URL, counting).length, 22);
assert.equal(randomToken(256, BASE64URL, counting).length, 43);
});
test('130 bits in Base32 take 26 characters, and 131 take 27', () => {
assert.equal(randomToken(130, BASE32, counting).length, 26);
assert.equal(randomToken(131, BASE32, counting).length, 27);
});
test('byte 0 picks the first character, byte 1 the second, and so on', () => {
assert.equal(randomToken(64, HEX, counting), '0123456789abcdef');
assert.equal(randomToken(30, BASE64URL, counting), 'ABCDE');
const fixed = (bytes) => {
bytes.set([2, 3, 4, 5, 255]);
return bytes;
};
assert.equal(randomToken(5, '01', fixed), '01011');
});
test('the source fills one buffer, one byte per character', () => {
const asked = [];
randomToken(128, BASE64URL, (bytes) => {
asked.push(bytes.length);
return counting(bytes);
});
assert.deepEqual(asked, [22]);
});
test('alphabets of 10, 62, 1 or 512 characters, a repeated character, and bits below 1 are refused', () => {
const wide = Array.from({ length: 512 }, (_, i) => String.fromCharCode(0x100 + i)).join(''); // more than a byte can pick
const cases = [[128, '0123456789'], [128, BASE64URL.slice(0, 62)], [128, 'a'], [128, wide], [128, 'aabbccdd'], [0, HEX], [-8, HEX]];
for (const [bits, alphabet] of cases) assert.throws(() => randomToken(bits, alphabet, counting), `${bits} bits in ${alphabet.slice(0, 12)}`);
}); A hint
With an alphabet of size 2ᵏ, each character carries k bits, so the length is bits / k rounded up: in Python -(-bits // k), in JavaScript Math.ceil(bits / k). A size is a power of two when size & (size - 1) is 0, and byte & (size - 1) keeps the byte's low k bits, which picks every character equally often.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python, JavaScript
Fill a large buffer 65,536 bytes at a time
crypto.getRandomValues refuses more than 65,536 bytes in one call. Write random_buffer(n, source) in random_buffer.py (JavaScript: randomBuffer(n, fill) in random_buffer.mjs) that returns n random bytes from a source with the same limit:
- Python:
source(k)returnskbytes and raisesValueErrorwhenkis above 65,536. Return abytesof lengthn, made from as few calls as possible, in order. - JavaScript:
fill(view)fills aUint8Arrayin place and throws when the view is longer than 65,536 bytes, ascrypto.getRandomValuesdoes. Return a newUint8Array(n)filled through views of it (subarray), so that nothing is copied. nof 0 gives an empty result without any call; a negative or fractionalnraisesValueError(JavaScript: throws anError).
The starter code asks for everything at once, which works only up to 65,536 bytes.
Python · Starter code · random_buffer.py
LIMIT = 65_536 # bytes per call, as crypto.getRandomValues allows
def random_buffer(n, source):
"""n random bytes from source(k), which refuses more than LIMIT bytes in one call."""
return source(n) # one call: fails for anything above 65,536 bytes The sample tests · test_random_buffer.py
from random_buffer import random_buffer
PATTERN = bytes(range(256)) * 258
class LimitedSource:
"""A test source with getRandomValues's limit: it counts 0, 1, 2 ... across calls and records each request."""
def __init__(self):
self.calls = []
self.position = 0
def __call__(self, k):
if k > 65_536:
raise ValueError(f"{k} bytes in one call: the limit is 65,536")
self.calls.append(k)
start = self.position % 256
self.position += k
return PATTERN[start : start + k]
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_small_and_exact():
"""up to 65,536 bytes take one call"""
for n in [1, 32, 65_536]:
source = LimitedSource()
assert len(random_buffer(n, source)) == n
assert source.calls == [n], f"{n} bytes"
def test_one_byte_over():
"""65,537 bytes take two calls: 65,536 and 1"""
source = LimitedSource()
assert len(random_buffer(65_537, source)) == 65_537
assert source.calls == [65_536, 1]
def test_one_mebibyte():
"""1 MiB takes 16 calls of 65,536 bytes, and the bytes keep their order"""
source = LimitedSource()
data = random_buffer(1 << 20, source)
assert source.calls == [65_536] * 16
assert data == PATTERN[:256] * 4096
def test_zero_bytes():
"""0 bytes: an empty result and no call at all"""
source = LimitedSource()
assert random_buffer(0, source) == b""
assert source.calls == []
def test_bad_sizes():
"""a negative or fractional size is refused"""
for bad in [-1, 2.5]:
assert raises_value_error(lambda: random_buffer(bad, LimitedSource())), f"{bad} should raise ValueError" JavaScript · Starter code · random_buffer.mjs
export const LIMIT = 65_536; // bytes per call, as crypto.getRandomValues allows
/** n random bytes from fill(view), which refuses views longer than LIMIT. */
export function randomBuffer(n, fill) {
const out = new Uint8Array(n);
fill(out); // one call: fails for anything above 65,536 bytes
return out;
} The sample tests · random_buffer.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { randomBuffer } from './random_buffer.mjs';
// A test fill with getRandomValues's limit: it counts 0, 1, 2 ... across calls and records the length of each view.
function limitedFill() {
let position = 0;
const calls = [];
const fill = (view) => {
if (view.length > 65_536) throw new Error(`${view.length} bytes in one call: the limit is 65,536`);
calls.push(view.length);
for (let i = 0; i < view.length; i++) view[i] = (position + i) & 0xff;
position += view.length;
return view;
};
return { fill, calls };
}
test('up to 65,536 bytes take one call', () => {
for (const n of [1, 32, 65_536]) {
const { fill, calls } = limitedFill();
assert.equal(randomBuffer(n, fill).length, n);
assert.deepEqual(calls, [n], `${n} bytes`);
}
});
test('65,537 bytes take two calls: 65,536 and 1', () => {
const { fill, calls } = limitedFill();
assert.equal(randomBuffer(65_537, fill).length, 65_537);
assert.deepEqual(calls, [65_536, 1]);
});
test('1 MiB takes 16 calls of 65,536 bytes, and the bytes keep their order', () => {
const { fill, calls } = limitedFill();
const data = randomBuffer(1 << 20, fill);
assert.deepEqual(calls, Array(16).fill(65_536));
let inOrder = true;
for (let i = 0; i < data.length; i += 4099) if (data[i] !== (i & 0xff)) inOrder = false;
assert.ok(inOrder, 'byte i should be i % 256');
});
test('0 bytes: an empty result and no call at all', () => {
const { fill, calls } = limitedFill();
assert.equal(randomBuffer(0, fill).length, 0);
assert.deepEqual(calls, []);
});
test('a negative or fractional size is refused', () => {
for (const bad of [-1, 2.5]) assert.throws(() => randomBuffer(bad, limitedFill().fill), `${bad} should throw`);
}); A hint
Step through the buffer in strides of 65,536: start at 0, 65,536, 131,072 … and ask each time for the smaller of 65,536 and what is left, min(65_536, n - start). In JavaScript out.subarray(start, start + 65_536) stops at the end of the buffer by itself.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then take a mixed run through the five ideas of this lesson. The first questions go to your weakest idea, a right answer earns a harder one, and anything you miss comes back before the end:
Practice that adapts to you
Practise this lesson
14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid level): why must Math.random() never make a session token, and what do you use instead?
Math.random() is a fast pseudo-random generator for simulations and games: every output follows from its internal
state by a fixed formula, and MDN says it must not be used for anything related to security. A session token must
be unguessable, so someone who learns enough about the generator’s state can predict the tokens of other users, and
take over their sessions. Use the platform’s cryptographically secure generator instead: crypto.getRandomValues() in
a browser or Node, crypto.randomBytes() in Node, secrets.token_urlsafe() in Python. Take at least 16 bytes, the
128 bits ASVS 5.0 asks for, encode them as Base64url or hex, and never seed that generator yourself. Python’s random
module has the same problem, and its documentation says so.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can a reset token made with Python's random module, seeded with the clock, be rebuilt?
Its output follows from the seed by a fixed formula, and a clock reading is easy to narrow down. In the lesson's run one of 3,601 seeds, one for each second of the hour before, rebuilt the 128-bit token exactly, although it had 62 one-bits out of 128 like a random value. A secrets token has no seed to find. Seeding with the time or process ID is CWE-337.
-
Does seeding random with os.urandom(32) make random.getrandbits(128) safe for a token?
No. The seed is unpredictable, but the generator is still a fast formula over its state, built for statistics rather than against someone working backwards from outputs it has shown. Take secrets straight from secrets or crypto.getRandomValues, which never let you choose a seed.
-
Where do secure random bytes come from, and which NIST standards describe the layers?
From the operating system's CSPRNG, which you neither build nor seed. SP 800-90B covers the entropy sources that measure unpredictable physical events, SP 800-90A the deterministic random bit generators that stretch them (Hash_DRBG, HMAC_DRBG, CTR_DRBG), and SP 800-90C how to combine the two. A CSPRNG is one whose next bit no practical computation predicts better than a coin toss.
-
What does crypto.getRandomValues refuse, and how do you fill a larger buffer?
It throws TypeMismatchError for anything but an integer typed array (a Float64Array fails) and QuotaExceededError for more than 65,536 bytes in one call. Fill a larger buffer in slices, using subarray views so each call writes into the right part without copying: the lesson filled 1 MiB in 16 calls.
-
Which random calls does Node.js add beyond getRandomValues?
randomBytes(n) returns a Buffer with no 65,536-byte limit, randomUUID() a version 4 UUID, and randomInt(min, max) an integer that avoids modulo bias. In a browser, randomUUID() exists only on secure pages, while getRandomValues works on any page.
-
How many random bytes do common secrets need, and how do Python's secrets functions size their output?
Session IDs, reset links and API tokens: 16 or more bytes (ASVS 5.0, 11.5.1). AES-128 key 16, AES-256 key 32, password salt 16, AES-GCM nonce 12. With no argument the token functions use secrets.DEFAULT_ENTROPY, which may change, so pass the size: token_bytes(16) is 16 bytes, token_hex(16) 32 characters, token_urlsafe(16) 22.
-
How should tests get predictable randomness without a seed in the code that makes secrets?
Pass the random source in as a parameter that defaults to the system generator: def issue_token(source=secrets.token_bytes). Tests hand in a fixed source such as lambda n: bytes(range(n)). A seed parameter on production code lets anyone who learns it, or a code path that forgets the default, rebuild the tokens.
-
What real failure shows the danger of a poorly seeded generator?
A change made only in Debian's OpenSSL package left the generator seeded with little more than the process ID, so SSH, OpenVPN, DNSSEC and certificate keys made on those systems could be guessed (Debian advisory DSA-1571, CVE-2008-0166). MITRE lists the weakness as CWE-337.
Key takeaways
- A generator built for simulations is predictable: a token seeded with the clock was rebuilt from 3,601 guesses, however random its 128 bits looked.
- Secure random bytes come from the operating system’s CSPRNG, built from an entropy source and a DRBG as NIST SP 800-90A, 800-90B and 800-90C describe, and no call in this lesson asks you for a seed.
crypto.getRandomValuesfills at most 65,536 bytes of an integer typed array per call, so larger buffers are filled in slices; Node addsrandomBytes,randomUUIDandrandomInt.- In Python, use
secretsoros.urandom, with at least 16 bytes for anything that must not be guessed and an explicit size wherever the size matters. - Tests get a fixed random source through a parameter; the code that ships uses the system generator and accepts no seed.
Terms in this lesson
- CSPRNG
- A cryptographically secure pseudo-random number generator: no practical computation on the bits it has given predicts its next bit noticeably better than a coin toss. Operating systems run one, seeded from an entropy source.
- DRBG
- A deterministic random bit generator, as NIST SP 800-90A specifies: Hash_DRBG, HMAC_DRBG or CTR_DRBG. It stretches a secret seed from an entropy source into as many random bytes as needed and is reseeded over time.
- entropy source
- The part of a random bit generator that turns unpredictable physical events into raw bits and tests its own health, as NIST SP 800-90B describes. Its output seeds a DRBG.
- seed (of a generator)
- The starting value of a pseudo-random number generator. The same seed always gives the same sequence, so a seed that can be guessed, such as the clock, makes every output guessable.
References
- Web Cryptography Level 2, Editor's Draft (sections 10.1.1 getRandomValues and 10.1.2 randomUUID) (World Wide Web Consortium (W3C))
- Node.js 24 documentation: Crypto (randomBytes, randomUUID, getRandomValues) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: secrets, generate secure random numbers for managing secrets (Python Software Foundation)
- Python 3.14 documentation: random, generate pseudo-random numbers (Python Software Foundation)
- Python 3.14 documentation: os.urandom (Python Software Foundation)
- MDN Web Docs: Math.random() (Mozilla)
- NIST SP 800-90A Rev. 1: Recommendation for Random Number Generation Using Deterministic Random Bit Generators (National Institute of Standards and Technology (NIST))
- NIST SP 800-90B: Recommendation for the Entropy Sources Used for Random Bit Generation (National Institute of Standards and Technology (NIST))
- NIST SP 800-90C: Recommendation for Random Bit Generator (RBG) Constructions (National Institute of Standards and Technology (NIST))
- RFC 4086 (BCP 106): Randomness Requirements for Security (Internet Engineering Task Force (IETF))
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.5.1) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, Appendix C: Cryptography Standards (random values) (OWASP Foundation)
- CWE-337: Predictable Seed in Pseudo-Random Number Generator (PRNG) (The MITRE Corporation)
- DSA-1571-1 openssl: predictable random number generator (Debian Project)
- Handbook of Applied Cryptography, chapter 5 (Definitions 5.6 and 5.8, the next-bit test) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- Python 3.14 documentation: hashlib (key derivation, salt size) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress