Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Applied Cryptography for Developers  Module 3 – Randomness and secret values

Random tokens, UUIDs and unbiased sampling

When a UUID is enough and when a secret token is needed, how long a token must be for its alphabet, and how to pick random values without modulo bias.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8, Pyodide 314.0.7 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Decide when a UUID is acceptable and when a secret token is required
  • Calculate token length from the alphabet and the entropy you need
  • Remove modulo bias with rejection sampling or a library function that does it
  • Estimate collision risk and guessing risk for random identifiers

Before you start

What you’ll learn · Before you start

What you will learn

  • Decide when a UUID is acceptable and when a secret token is required
  • Calculate token length from the alphabet and the entropy you need
  • Remove modulo bias with rejection sampling or a library function that does it
  • Estimate collision risk and guessing risk for random identifiers

Before you start

On this page

A version 4 UUID has 122 random bits, enough that a trillion of them repeat with a chance of about 10⁻¹³, and yet RFC 9562 says UUIDs must not be used as security capabilities, the very job of a password-reset link. Being unique and being impossible to guess are different jobs, and an identifier can fail the second while passing the first.

This lesson sorts out the jobs. You decide when a UUID is acceptable and when a secret token is required, work out how many characters a token needs in hex, Base32, Base58 or Base64url, measure the modulo bias that byte % 10 creates and remove it with rejection sampling, and estimate two different risks for random identifiers: that two collide, and that someone guesses one. A two-step code lab at the end builds an unbiased number and an unbiased string.

A UUID names a record; a secret token proves something

RFC 9562 defines the UUID formats in use today. A version 4 UUID is 128 bits with 6 of them fixed: 4 bits say “version 4” and 2 say which variant of the format it is, which leaves 122 random bits. A version 7 UUID starts with the time in milliseconds, so that new ones sort after old ones and inserts stay close together in a database index: RFC 9562 reports that random keys such as version 4 have poor index locality, with differences of an order of magnitude or more. The program takes one of each apart:

Version 4 and version 7 UUIDs, bit by bit Python · uuids/uuids.py
"""Version 4 and version 7 UUIDs taken apart: which bits are fixed, which are random, and what a v7 tells about itself."""
import time
import uuid

v4 = uuid.uuid4()
print(f"uuid4: version {v4.version}, variant RFC 4122/9562: {v4.variant == uuid.RFC_4122}")
print(f"  the 13th hex digit is always 4: {str(v4)[14] == '4'}; the 17th is one of 8, 9, a, b: {str(v4)[19] in '89ab'}")
print(f"  random bits: {128 - 4 - 2} of 128")

before = time.time_ns() // 1_000_000
first, second = uuid.uuid7(), uuid.uuid7()
after = time.time_ns() // 1_000_000
stamp = int(first.hex[:12], 16)  # the first 48 bits: milliseconds since 1970
print(f"uuid7: version {first.version}, variant RFC 4122/9562: {first.variant == uuid.RFC_4122}")
print(f"  its first 48 bits are the clock in milliseconds: {before <= stamp <= after}")
print(f"  a later uuid7 sorts after it: {second > first}")
print(f"  bits that are neither clock nor fixed: {128 - 48 - 4 - 2} of 128")

Output

uuid4: version 4, variant RFC 4122/9562: True
  the 13th hex digit is always 4: True; the 17th is one of 8, 9, a, b: True
  random bits: 122 of 128
uuid7: version 7, variant RFC 4122/9562: True
  its first 48 bits are the clock in milliseconds: True
  a later uuid7 sorts after it: True
  bits that are neither clock nor fixed: 74 of 128

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 uuids.py

The fixed digits are visible in every UUID: the 13th hex digit is the version, and the 17th is one of 8, 9, a or b. The version 7 UUID carried the clock reading of the moment it was made, and the second one sorted after the first.

Bits Version 4 Version 7
0 to 47 random the Unix time in milliseconds
48 to 51 the version, 4 the version, 7
52 to 63 random random, or a counter
64 and 65 the variant, binary 10 the variant, binary 10
66 to 127 random random, or a counter
Random bits 122 at most 74

A UUID is built to be unique, not secret. RFC 9562 warns implementations against assuming that UUIDs are hard to guess, and forbids using them as security capabilities, values that grant access to whoever holds them. ASVS 5.0 adds, in requirement 11.5.1, that UUIDs fall short of its rule for values nobody may guess, which asks for a CSPRNG and 128 bits or more. Version 7 also gives away when a record was created. Python’s uuid.uuid7() uses a 42-bit counter so that UUIDs made within one millisecond stay in order, and a counter is the opposite of a secret. Node added crypto.randomUUIDv7() in 24.16.0 and Python uuid.uuid7() in 3.14; crypto.randomUUID() and uuid.uuid4() give version 4.

Which value fits depends on what holding it allows. Answer the three questions in the table:

Interactive · answer and compare What should this value be? Answer three questions about the value: whether holding it grants anything, whether keys must sort by time, and whether people type it. The table marks what fits.
Does holding the value grant access or prove anything, as a reset link or an API key does?
Must the values sort by creation time, for example as a primary key?
Will people read the value aloud or type it?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five kinds of identifier against four things a design asks of them
Value Hard to guess What it reveals As a database key For people to type
A sequential number Made of: a database counter Poor the next value is the last one plus 1 Poor how many records exist, and in what order Good grows in order, so inserts stay at the end Good short and digits only
UUID version 4 Made of: 122 random bits Mixed 122 random bits, but RFC 9562: never a security capability Good nothing Mixed RFC 9562: poor index locality, inserts at random places Poor 36 characters with dashes
UUID version 7 Made of: the time, then 74 bits Poor its first 48 bits are the clock Mixed when it was made, to the millisecond Good sorts by creation time, byte by byte Poor 36 characters with dashes
A 128-bit random token Made of: 16 bytes from a CSPRNG Good 128 bits from a CSPRNG, the ASVS 5.0 floor Good nothing Mixed random order, like version 4 Mixed 22 Base64url or 26 Base32 characters
A 6-digit code Made of: about 20 bits Poor one in a million per try: needs few tries and a short life Good nothing Poor far too few values to be unique Good 6 digits
How the answers decide
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” Yes, it is a secret: rules out A sequential number, UUID version 4 and UUID version 7. A counter is guessed by adding 1, RFC 9562 says UUIDs must not be used as security capabilities, and ASVS 5.0 says they miss its 128-bit rule.
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” Yes, it is a secret and “Will people read the value aloud or type it?” Yes: adds a caveat to A 6-digit code. A short code is a secret only with throttling and a short life: SP 800-63B-4 allows at most 100 failed attempts, and 100 tries hit a 6-digit code once in 10,000.
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” Yes, it is a secret and “Will people read the value aloud or type it?” No, machines copy it: rules out A 6-digit code. Nothing forces a short value when machines copy it: use the 128-bit token.
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” No, it only names a record: rules out A 6-digit code. A million values cannot name records uniquely for long: 100,000 random codes of 30 bits already repeat with a chance of 0.99.
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” No, it only names a record and “Must the values sort by creation time, for example as a primary key?” Yes: adds a caveat to UUID version 4 and A 128-bit random token. Random keys are inserted at random places in the index; RFC 9562 reports differences of an order of magnitude or more for time-ordered keys.
  • “Does holding the value grant access or prove anything, as a reset link or an API key does?” No, it only names a record and “Must the values sort by creation time, for example as a primary key?” No: adds a caveat to A sequential number. Fine inside the database, but in a public URL it tells everyone how many records exist and which came first.
  • “Will people read the value aloud or type it?” Yes: adds a caveat to UUID version 4, UUID version 7 and A 128-bit random token. Long values are copied, not typed: write a typed token in Crockford Base32, 26 characters for 128 bits, without letters that look alike.

In short: a database counter or a version 7 UUID to name records, a version 4 UUID where the order should stay hidden, and a 128-bit random token whenever holding the value grants anything.

Checkpoint

Check: UUIDs name, tokens prove

  1. Question 1 of 2 A team makes password-reset links with crypto.randomUUID(). What does RFC 9562 say about that use?
    Show the answer: Question 1 of 2

    Answer: UUIDs must not be used as security capabilities, values whose mere possession grants access

    RFC 9562 tells implementations not to assume UUIDs are hard to guess and forbids using them as security capabilities. A reset link needs a token of 16 or more bytes from the secure generator, as ASVS 5.0 requirement 11.5.1 asks.

  2. Question 2 of 2 An order page shows its order ID, a version 7 UUID. What can a visitor learn from it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The time the order was created, to the millisecond

    A version 7 UUID is the time in milliseconds, then the version and variant and up to 74 random or counter bits. It sorts well as a key, and it tells everyone when each record was made.

Token length: characters times the bits each one carries

When every character is picked uniformly and independently from an alphabet of size characters, a token of length characters carries length × log₂(size) bits of entropy (of a token): The number of bits an attacker has to guess. A token of L characters, each drawn uniformly and independently from an alphabet of n, carries L × log2(n) bits; a fixed part such as a prefix carries none., the number of bits an attacker must guess. A hex character carries 4 bits, a Base64url character 6, a decimal digit only 3.32. The shortest token for a target of b bits is the smallest length L with size^L ≥ 2^b, which BigInt can check exactly instead of trusting a rounded logarithm:

Characters needed for 128 bits, alphabet by alphabet JavaScript · entropy-js/entropy.mjs
// How many characters a token needs for 128 bits, alphabet by alphabet, worked out exactly with BigInt:
// the smallest length L with size ** L >= 2 ** 128.
const ALPHABETS = [
  ['digits 0-9', 10],
  ['hex', 16],
  ['lowercase a-z', 26],
  ['Crockford Base32', 32],
  ['lowercase and digits', 36],
  ['Base58 (no 0, O, I, l)', 58],
  ['letters and digits (Base62)', 62],
  ['Base64url', 64],
];

function lengthFor(bits, size) {
  const target = 2n ** BigInt(bits);
  let length = 0;
  let reach = 1n;
  while (reach < target) {
    reach *= BigInt(size);
    length++;
  }
  return length;
}

console.log('alphabet                       size  characters  bits carried');
for (const [name, size] of ALPHABETS) {
  const length = lengthFor(128, size);
  const carried = (length * Math.log2(size)).toFixed(1);
  console.log(`${name.padEnd(31)}${String(size).padStart(4)}${String(length).padStart(12)}${carried.padStart(14)}`);
}
console.log(`a UUIDv4 string: 36 characters, 122 random bits`);
console.log(`a 6-digit code: 6 characters, ${(6 * Math.log2(10)).toFixed(1)} bits`);

Output

alphabet                       size  characters  bits carried
digits 0-9                       10          39         129.6
hex                              16          32         128.0
lowercase a-z                    26          28         131.6
Crockford Base32                 32          26         130.0
lowercase and digits             36          25         129.2
Base58 (no 0, O, I, l)           58          22         128.9
letters and digits (Base62)      62          22         131.0
Base64url                        64          22         132.0
a UUIDv4 string: 36 characters, 122 random bits
a 6-digit code: 6 characters, 19.9 bits

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node entropy.mjs

For 128 bits, hex needs 32 characters, Crockford Base32 26, and Base58, Base62 and Base64url 22 each, while decimal digits need 39. A larger alphabet shortens the token but never adds entropy by itself: 22 Base64url characters carry 132 bits because each one carries 6. The last two lines show why a UUID string and a 6-digit code are different objects: 36 characters for 122 bits, and 6 characters for about 20.

Counting characters instead of bits

Wrong: “the token is 32 characters, so it is strong”, for 32 decimal digits. They carry 32 × 3.32 = 106 bits, under the 128 that ASVS 5.0 asks for. Right: multiply the length by log₂ of the alphabet’s size, and size the token from the bits: 39 digits, 26 Base32 characters or 22 Base64url characters.

Checkpoint

Check: Token length from the alphabet

  1. Question 1 of 2 How many Crockford Base32 characters does a token need for 128 bits?

    Type a number.

    A hint

    Each Base32 character carries 5 bits.

    Show the answer: Question 1 of 2

    Answer: 26 characters

    128 ÷ 5 = 25.6, so 25 characters carry only 125 bits and 26 are needed; they carry 130 bits, as the program's table shows.

  2. Question 2 of 2 About how many bits does a code of 12 random decimal digits carry? Give one decimal place.

    Type a number.

    A hint

    A digit carries log₂ 10, about 3.32 bits.

    Show the answer: Question 2 of 2

    Answer: 39.9 bits (anything from 39.8 to 40 counts)

    12 × log₂ 10 = 12 × 3.3219 = 39.9 bits: about as much as 8 Base32 characters, far from the 128 bits a secret token needs.

byte mod n favours the small values unless n divides 256

A random byte takes 256 values, and 256 = 25 × 10 + 6. Map bytes to digits with byte % 10 and the six bytes 250 to 255 land on 0 to 5, so those digits come from 26 bytes each and 6 to 9 from 25: each of 0 to 5 is 4 % more likely than each of 6 to 9. This uneven result is called modulo bias: The unevenness left by reducing random values with %: byte % 10 gives 0 to 5 from 26 of the 256 byte values each and 6 to 9 from 25. It disappears only when the size divides the range.. The program counts it on a million seeded bytes, next to the fix of the next section, and scores both with a chi-squared statistic: A measure of how far observed counts are from expected ones: the sum of (observed - expected)^2 / expected. Scored against English letter frequencies, the lowest value marks the most English-looking text. More in “The symmetric cipher model and the Caesar cipher”:

byte % 10 against rejection sampling, a million digits each Python · bias/bias.py
"""Modulo bias, counted: digits from byte % 10 against digits from rejection sampling, on seeded bytes."""
import random
from collections import Counter

rng = random.Random(2026)  # a seeded simulation generator, so the counts repeat on every run; never for secrets
N = 1_000_000


def chi_squared(counts, total):
    expected = total / 10
    return sum((counts[d] - expected) ** 2 / expected for d in range(10))


every_byte = Counter(b % 10 for b in range(256))
print("each byte value 0-255 once, byte % 10:", [every_byte[d] for d in range(10)])

modulo = Counter(b % 10 for b in rng.randbytes(N))

sampled, taken, rejected = Counter(), 0, 0
while taken < N:
    for b in rng.randbytes(4096):
        if taken == N:
            break
        if b >= 250:  # 250 to 255 would favour 0 to 5: draw again
            rejected += 1
            continue
        sampled[b % 10] += 1
        taken += 1

print(f"\n{'digit':<7}{'byte % 10':>11}{'rejection':>11}")
for d in range(10):
    print(f"{d:<7}{modulo[d]:>11,}{sampled[d]:>11,}")
print("\nchi-squared (9 degrees of freedom, 5 % critical value 16.92):")
print(f"  byte % 10: {chi_squared(modulo, N):,.1f}")
print(f"  rejection: {chi_squared(sampled, N):,.1f}")
print(f"bytes rejected: {rejected:,} ({rejected / (N + rejected):.2%} of those drawn; 6 of 256 is {6 / 256:.2%})")

Output

each byte value 0-255 once, byte % 10: [26, 26, 26, 26, 26, 26, 25, 25, 25, 25]

digit    byte % 10  rejection
0          101,706     99,325
1          101,189    100,098
2          101,865    100,032
3          101,421    100,297
4          101,452     99,908
5          101,052     99,513
6           97,837     99,929
7           97,674    100,672
8           97,725    100,330
9           98,079     99,896

chi-squared (9 degrees of freedom, 5 % critical value 16.92):
  byte % 10: 319.9
  rejection: 13.8
bytes rejected: 23,724 (2.32% of those drawn; 6 of 256 is 2.34%)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bias.py

The digits 0 to 5 came up about 101,500 times each and 6 to 9 about 97,800. Against a uniform result the chi-squared score is 319.9, far above 16.92, the value a fair generator exceeds only 5 % of the time with 9 degrees of freedom (NIST’s e-Handbook table). Rejection sampling scored 13.8 on the same seeded bytes and threw away 2.32 % of them, close to the 6 in 256 the arithmetic predicts. The generator here is seeded on purpose, so the counts repeat; the bias comes from the %, whatever generator feeds it.

How much the bias matters depends on the size, and some standards accept a small one knowingly:

How biased byte % n is, and the bias HOTP accepts Python · how-biased/how_biased.py
"""How much byte % n favours some values, for common alphabet sizes, and the bias that HOTP accepts (RFC 4226)."""
from fractions import Fraction

print(f"{'n':>4}  {'values favoured':>15}  {'how much more likely':>20}")
for n in [2, 10, 16, 26, 36, 58, 62, 64, 100]:
    low, extra = divmod(256, n)  # each value gets `low` bytes, and `extra` values get one more
    if extra == 0:
        print(f"{n:>4}  {'none':>15}  {'unbiased':>20}")
    else:
        print(f"{n:>4}  {f'{extra} of {n}':>15}  {f'{(low + 1) / low - 1:.1%}':>20}")

# HOTP (RFC 4226) turns a 31-bit number into a 6-digit code with "mod 10**6".
space, codes = 2**31, 10**6
per_code, favoured = divmod(space, codes)
best_guess = Fraction(per_code + 1, space)
print(f"\nHOTP: 2**31 = {space:,} numbers onto {codes:,} codes")
print(f"  codes below {favoured:,} come from {per_code + 1:,} numbers, the rest from {per_code:,}")
print(f"  best single guess succeeds with {float(best_guess * codes):.8f} / 10**6")

Output

   n  values favoured  how much more likely
   2             none              unbiased
  10          6 of 10                  4.0%
  16             none              unbiased
  26         22 of 26                 11.1%
  36          4 of 36                 14.3%
  58         24 of 58                 25.0%
  62          8 of 62                 25.0%
  64             none              unbiased
 100        56 of 100                 50.0%

HOTP: 2**31 = 2,147,483,648 numbers onto 1,000,000 codes
  codes below 483,648 come from 2,148 numbers, the rest from 2,147
  best single guess succeeds with 1.00024045 / 10**6

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 how_biased.py

For 62 characters, the letters and digits of Base62, byte % 62 makes 8 of the characters 25 % more likely, and for 100 values 56 of them become 50 % more likely. Sizes that divide 256, such as 16 and 64, have no bias at all. HOTP, the one-time-password standard of RFC 4226, reduces a 31-bit number modulo 10⁶, so some codes are a little more likely than others; its Appendix A puts the best guess at 1.00024045 in a million, which the program reproduces, and the standard accepts that because it limits guesses anyway.

Checkpoint

Check: Modulo bias

  1. Question 1 of 2 A token generator picks each Base62 character with alphabet[byte % 62]. Which characters come up more often, and by how much?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first 8 characters of the alphabet, each 25 % more often than the other 54

    With 256 = 4 × 62 + 8, positions 0 to 7 get 5 of the 256 byte values each and the other 54 get 4, a 25 % edge that the lesson's table shows and that a rejection cut-off at 248 removes.

  2. Question 2 of 2 For which alphabet sizes is alphabet[byte % size] unbiased?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • 64
    • 16

    The remainder is fair only when the size divides 256, that is, for the powers of two up to 256. Every other size leaves some values with one byte more than the rest.

Rejection sampling: throw away the values that break the tie

The fix is to use only values that split evenly, which is called rejection sampling: Drawing again whenever a random value falls at or above the largest multiple of the size you need, so that every result comes from the same number of values and is exactly uniform.. The Handbook of Applied Cryptography describes it for random bits turned into numbers: if the result is too large, discard it and draw again. For digits, keep a byte only if it is below 250, the largest multiple of 10 that fits in a byte; then every digit comes from exactly 25 bytes. Step through one draw in which the first byte is thrown away:

Interactive · stage by stage One fair digit from random bytes, with rejection Step through one draw. The first byte is thrown away and the second becomes the digit; the last step says what it costs.
One fair digit from random bytes, with rejection, step 6 of 6. Showing Draw one random byte (this time: 47), Is it below 250?, digit = byte % 10 (47 % 10 = 7), A fair digit (the digit is 7). In focus: A fair digit.250 to 255: againDraw one random bytethis time: 47Is it below 250?250 is 25 times 10digit = byte % 1047 % 10 = 7A fair digitthe digit is 7

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A byte from the generator. Take one byte from the secure generator. Each of its 256 values is equally likely, but 256 is not a multiple of 10, so the remainder alone cannot be fair.
  2. The cut-off. 250 is the largest multiple of 10 that fits in a byte. The bytes 0 to 249 split into ten groups of 25, one group for each digit, so only those bytes are kept.
  3. Thrown away. 253 is 250 or more. Taken mod 10 it would give 3, and the six bytes 250 to 255 would make 0 to 5 more likely than 6 to 9. So the byte is discarded and a new one drawn, which happens 6 times in 256.
  4. The second byte. The next byte is 47, which is below 250, so it is kept.
  5. The remainder is now fair. 47 % 10 is 7. Every digit comes from exactly 25 of the 250 bytes that are kept, so each one has a chance of 1 in 10.
  6. The cost. A digit costs 256 / 250 = 1.024 bytes on average. Node's crypto.randomInt and Python's secrets.randbelow and secrets.choice do this work for you.

A second form needs no division at all: keep only as many low bits as the bound needs, 4 bits for a bound of 10, and draw again whenever the value is the bound or more. It throws away more draws for some bounds, but it works the same way for a bound of 1,000 or of 2³², and the first step of the code lab uses it. Either way the result is exactly uniform, and the expected number of draws stays below 2.

You rarely need to write it yourself. Node’s crypto.randomInt(min, max) returns an integer in the range and its documentation says it avoids modulo bias, for ranges below 2⁴⁸; Python’s secrets.randbelow(n) and secrets.choice(seq) give uniform results from the system generator. Write your own only where no such call exists, as in a format that picks characters from its own alphabet, and test it on every byte value, as the code lab does.

Key idea

Never reduce random bytes with % unless the size divides 256: keep only the values below the largest multiple of the size, or call randomInt, randbelow or choice, which already do.

Checkpoint

Check: Rejection sampling

  1. Question 1 of 2 To pick one of 26 letters fairly from single bytes, which byte values do you keep? Give the cut-off, the first byte value you throw away.

    Type a number.

    A hint

    Find the largest multiple of 26 that is at most 256.

    Show the answer: Question 1 of 2

    Answer: 234 (bytes 0 to 233 kept)

    9 × 26 = 234, and 10 × 26 = 260 is too large. Keeping the bytes 0 to 233 gives each letter exactly 9 of them; the 22 bytes 234 to 255 are drawn again, about 8.6 % of the time.

  2. Question 2 of 2 In Node, which call picks a uniform number from 0 to 999 without your own sampling code?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: crypto.randomInt(0, 1000)

    crypto.randomInt(min, max) returns min ≤ n < max from the secure generator and avoids modulo bias, for ranges below 2⁴⁸. In Python, secrets.randbelow(1000) does the same.

Collisions and guesses are two different questions

A random identifier can fail in two ways, and they need different sums. A collision is two identifiers that come out the same, which matters for uniqueness; by the birthday bound: The rule that random values of b bits start to repeat after about 2^(b/2) of them. It is why a 256-bit hash gives about 128 bits of collision resistance and why random nonces have usage limits. More in “Security levels, key sizes and attack cost” its chance grows with the square of the number made. A guess is someone hitting any identifier that is live, which matters for security; its chance grows with the number of live values times the number of guesses. The program works out both for typical cases:

Chance of a duplicate, and chance of a correct guess Python · collisions/collisions.py
"""Two questions about random identifiers: will two of them collide, and can someone guess a valid one?"""
import math


def any_duplicate(n, bits):
    """Birthday bound: the chance that n random values of `bits` bits are not all different."""
    return -math.expm1(-n * (n - 1) / 2 ** (bits + 1))


def guess_one(valid, guesses, bits):
    """The chance that `guesses` random tries hit any of `valid` live values out of 2**bits."""
    return -math.expm1(guesses * math.log1p(-valid / 2**bits))


print("Will two collide?                         chance of any duplicate")
for label, n, bits in [
    ("10**5 codes of 6 Base32 characters", 10**5, 30),
    ("10**9 IDs of 64 random bits", 10**9, 64),
    ("10**12 version 4 UUIDs", 10**12, 122),
    ("10**12 tokens of 128 bits", 10**12, 128),
]:
    print(f"  {label:<38}{any_duplicate(n, bits):.2e}")

print("\nCan someone guess one?                    chance of a hit")
year_of_guesses = 10**6 * 365  # a million tries a day for a year
for label, valid, guesses, bits in [
    ("6-digit code, 100 tries, then locked", 1, 100, math.log2(10**6)),
    ("6-digit code, a year with no limit", 1, year_of_guesses, math.log2(10**6)),
    ("10**9 live 64-bit tokens, a year", 10**9, year_of_guesses, 64),
    ("10**9 live 128-bit tokens, a year", 10**9, year_of_guesses, 128),
]:
    print(f"  {label:<38}{guess_one(valid, guesses, bits):.2e}")

Output

Will two collide?                         chance of any duplicate
  10**5 codes of 6 Base32 characters    9.91e-01
  10**9 IDs of 64 random bits           2.67e-02
  10**12 version 4 UUIDs                9.40e-14
  10**12 tokens of 128 bits             1.47e-15

Can someone guess one?                    chance of a hit
  6-digit code, 100 tries, then locked  1.00e-04
  6-digit code, a year with no limit    1.00e+00
  10**9 live 64-bit tokens, a year      1.96e-02
  10**9 live 128-bit tokens, a year     1.07e-21

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 collisions.py

A hundred thousand gift codes of 6 Base32 characters, 30 bits, almost surely repeat: the chance is 0.99. A billion 64-bit identifiers repeat with a chance of 2.67 %, and a trillion version 4 UUIDs with 9.4 × 10⁻¹⁴, which is why UUIDs work well as names. Guessing is the second half of the output. With a billion live 64-bit tokens and a million guesses a day, a year of guessing hits one with a chance of about 2 %; with 128-bit tokens it is 10⁻²¹, which is why secrets get 128 bits even where 64 would be unique enough.

Short codes that people type, such as the six digits of a one-time password, carry about 20 bits by design, and they stay safe only because the verifier limits the guesses and the code expires. RFC 4226 asks the server to throttle failed attempts, and NIST SP 800-63B-4 caps consecutive failures at 100 before the authenticator is disabled, requires rate limiting for any such secret under 64 bits, and gives an out-of-band code at most 10 minutes. With 100 tries the chance of hitting a 6-digit code is 1 in 10,000; with a year of unlimited tries it is certain.

Checkpoint

Check: Collisions and guesses

  1. Question 1 of 2 A shop prints 100,000 random gift codes of 6 Base32 characters without checking for duplicates. What does the lesson's program say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Some two codes are almost certainly the same, with a chance of about 0.99

    The birthday bound gives 1 − e^(−n²/2^(b+1)) = 1 − e^(−10¹⁰/2³¹) ≈ 0.99. Check each new code against those already issued, or use more characters.

  2. Question 2 of 2 A verifier allows 100 failed tries before it disables a 6-digit code. What is the chance, as a fraction, that 100 random guesses hit it?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0.0001 (a fraction) (anything from 0.000099 to 0.000101 counts)

    Each guess hits with a chance of 1 in 10⁶, and 100 different guesses cover 100 of the million codes: 100 ÷ 10⁶ = 0.0001, the 1.00e-04 of the program. NIST SP 800-63B-4 sets 100 as the upper limit.

Practise: an unbiased number and an unbiased string

Two exercises with sample tests on this page build what every token generator needs. The first returns a uniform number below any bound up to 2³² from an injected byte source, keeping only the bits the bound needs and drawing again when the value is too large. The second makes a string from any alphabet of up to 256 characters with the cut-off of this lesson, held to an exhaustive test over every byte value:

Code lab · 2 steps

Build unbiased random choices

Return a uniform number below any bound from random bytes, then a string from any alphabet with every character equally likely.

  1. A uniform random number below a bound To do
  2. A random string from any alphabet, without bias To do

Exercise · Medium · Python, JavaScript

A uniform random number below a bound

Write uniform_int(max_exclusive, source) in uniform_int.py (JavaScript: uniformInt(maxExclusive, source) in uniform_int.mjs). It returns a whole number from 0 to max_exclusive - 1, every one equally likely, for any bound from 1 to 2³²:

  • Keep only the bits the bound needs: k, the bit length of max_exclusive - 1. Take the fewest bytes that hold k bits from source, read them big-endian (the first byte is the most significant), keep the low k bits, and if the result is not below the bound, draw again. A bound of 1 needs no bytes at all: the answer is 0.
  • In Python source(n) returns n bytes; in JavaScript source(bytes) fills the Uint8Array it is given. Ask for the bytes of one try per call.
  • Raise ValueError (JavaScript: throw an Error) for a bound below 1, above 2³², or that is not a whole number.

The starter code returns byte % max_exclusive, the modulo bias of the lesson.

Python · Starter code · uniform_int.py

def uniform_int(max_exclusive, source):
    """A whole number from 0 to max_exclusive - 1, every one equally likely. source(n) returns n random bytes."""
    return source(1)[0] % max_exclusive  # modulo bias, and never above 255
The sample tests · test_uniform_int.py
from collections import Counter

from uniform_int import uniform_int


class Bytes:
    """A test source that hands out the given bytes in order and counts how many it gave."""

    def __init__(self, data):
        self.data = bytes(data)
        self.given = 0

    def __call__(self, n):
        chunk = self.data[self.given : self.given + n]
        assert len(chunk) == n, "the source ran out: too many bytes were asked for"
        self.given += n
        return chunk


def raises_value_error(call):
    try:
        call()
    except ValueError:
        return True
    return False


def test_every_byte_once_gives_a_flat_count():
    """bound 10 over the bytes 0-249: each digit 16 times, and the 90 bytes with low bits 10-15 skipped"""
    source = Bytes(range(256))
    counts = Counter(uniform_int(10, source) for _ in range(160))
    assert [counts[d] for d in range(10)] == [16] * 10
    assert source.given == 250


def test_high_bytes_are_never_used_as_they_are():
    """bound 10: the bytes 250-255 are all refused, then 7 is kept"""
    source = Bytes([250, 251, 252, 253, 254, 255, 7])
    assert uniform_int(10, source) == 7
    assert source.given == 7


def test_rejection_branch():
    """bound 10: byte 12 has low bits 12, too big, so a second byte is drawn"""
    source = Bytes([12, 3])
    assert uniform_int(10, source) == 3
    assert source.given == 2


def test_two_bytes_big_endian():
    """bound 1000 needs 10 bits in 2 bytes: 0x03E8 (1000) is rejected, 0x03E7 (999) kept"""
    source = Bytes([0x03, 0xE8, 0x03, 0xE7])
    assert uniform_int(1000, source) == 999
    assert source.given == 4


def test_ends_of_the_range():
    """bound 1 needs no byte; bound 2**32 keeps all 32 bits"""
    source = Bytes([])
    assert uniform_int(1, source) == 0
    assert source.given == 0
    assert uniform_int(2**32, Bytes([0xFF, 0xFF, 0xFF, 0xFF])) == 2**32 - 1
    assert uniform_int(2**32, Bytes([0x12, 0x34, 0x56, 0x78])) == 0x12345678


def test_bad_bounds():
    """0, -5, 2**32 + 1 and 2.5 are refused"""
    for bad in [0, -5, 2**32 + 1, 2.5]:
        assert raises_value_error(lambda: uniform_int(bad, Bytes([1, 2, 3, 4]))), f"{bad} should raise ValueError"

JavaScript · Starter code · uniform_int.mjs

/** A whole number from 0 to maxExclusive - 1, every one equally likely. source(bytes) fills a Uint8Array. */
export function uniformInt(maxExclusive, source) {
  const byte = source(new Uint8Array(1))[0];
  return byte % maxExclusive; // modulo bias, and never above 255
}
The sample tests · uniform_int.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { uniformInt } from './uniform_int.mjs';

// A test source that hands out the given bytes in order and counts how many it gave.
function bytesSource(list) {
  const state = { given: 0 };
  state.fill = (bytes) => {
    if (state.given + bytes.length > list.length) throw new Error('the source ran out: too many bytes were asked for');
    for (let i = 0; i < bytes.length; i++) bytes[i] = list[state.given + i];
    state.given += bytes.length;
    return bytes;
  };
  return state;
}

test('bound 10 over the bytes 0-249: each digit 16 times, and the 90 bytes with low bits 10-15 skipped', () => {
  const source = bytesSource(Array.from({ length: 256 }, (_, i) => i));
  const counts = Array(10).fill(0);
  for (let i = 0; i < 160; i++) counts[uniformInt(10, source.fill)]++;
  assert.deepEqual(counts, Array(10).fill(16));
  assert.equal(source.given, 250);
});

test('bound 10: the bytes 250-255 are all refused, then 7 is kept', () => {
  const source = bytesSource([250, 251, 252, 253, 254, 255, 7]);
  assert.equal(uniformInt(10, source.fill), 7);
  assert.equal(source.given, 7);
});

test('bound 10: byte 12 has low bits 12, too big, so a second byte is drawn', () => {
  const source = bytesSource([12, 3]);
  assert.equal(uniformInt(10, source.fill), 3);
  assert.equal(source.given, 2);
});

test('bound 1000 needs 10 bits in 2 bytes: 0x03E8 (1000) is rejected, 0x03E7 (999) kept', () => {
  const source = bytesSource([0x03, 0xe8, 0x03, 0xe7]);
  assert.equal(uniformInt(1000, source.fill), 999);
  assert.equal(source.given, 4);
});

test('bound 1 needs no byte; bound 2**32 keeps all 32 bits', () => {
  const empty = bytesSource([]);
  assert.equal(uniformInt(1, empty.fill), 0);
  assert.equal(empty.given, 0);
  assert.equal(uniformInt(2 ** 32, bytesSource([0xff, 0xff, 0xff, 0xff]).fill), 2 ** 32 - 1);
  assert.equal(uniformInt(2 ** 32, bytesSource([0x12, 0x34, 0x56, 0x78]).fill), 0x12345678);
});

test('0, -5, 2**32 + 1 and 2.5 are refused', () => {
  for (const bad of [0, -5, 2 ** 32 + 1, 2.5]) assert.throws(() => uniformInt(bad, bytesSource([1, 2, 3, 4]).fill), `${bad} should throw`);
});
A hint

For a bound of 10, max_exclusive - 1 is 9, which has 4 bits ((9).bit_length() in Python, 32 - Math.clz32(9) in JavaScript). One byte holds them: keep byte & 0b1111, a value from 0 to 15, and try again while it is 10 or more. Two bytes b0, b1 read big-endian are b0 * 256 + b1.

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 · Medium · Python, JavaScript

A random string from any alphabet, without bias

Write random_string(length, alphabet, source) in random_string.py (JavaScript: randomString(length, alphabet, source) in random_string.mjs). It returns length characters from alphabet, each one equally likely, for any alphabet of 2 to 256 distinct characters, such as the 62 letters and digits of Base62:

  • Take one byte at a time from source (Python: source(1) returns one byte; JavaScript: source(bytes) fills a Uint8Array of length 1). Keep a byte only if it is below the largest multiple of the alphabet's size that fits in 256 values, and turn a kept byte b into alphabet[b % size]. For 62 characters that cut-off is 248, so the bytes 248 to 255 are drawn again.
  • Raise ValueError (JavaScript: throw an Error) for an alphabet of fewer than 2 or more than 256 characters, for a repeated character, and for a length that is negative or not a whole number. A length of 0 gives an empty string.

The starter code keeps every byte, which makes the first 8 of 62 characters 25 % more likely.

Python · Starter code · random_string.py

def random_string(length, alphabet, source):
    """length characters from alphabet, each equally likely. source(1) returns one random byte."""
    return "".join(alphabet[source(1)[0] % len(alphabet)] for _ in range(length))  # modulo bias
The sample tests · test_random_string.py
from collections import Counter

from random_string import random_string

BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"


class Bytes:
    """A test source that hands out the given bytes, one per call, and counts them."""

    def __init__(self, data):
        self.data = list(data)
        self.given = 0

    def __call__(self, n):
        assert n == 1, "ask for one byte at a time"
        assert self.given < len(self.data), "the source ran out: too many bytes were asked for"
        self.given += 1
        return bytes([self.data[self.given - 1]])


def raises_value_error(call):
    try:
        call()
    except ValueError:
        return True
    return False


def test_every_byte_once_gives_a_flat_count():
    """the 256 byte values, shuffled: 248 Base62 characters, each exactly 4 times, and all 256 bytes used"""
    source = Bytes((3 * b + 7) % 256 for b in range(256))
    counts = Counter(random_string(248, BASE62, source))
    assert sorted(set(counts.values())) == [4]
    assert len(counts) == 62
    assert source.given == 256


def test_bytes_above_the_cut_off_are_drawn_again():
    """Base62: the bytes 248-255 are refused, then 5 gives the character 5"""
    source = Bytes([248, 249, 250, 251, 252, 253, 254, 255, 5])
    assert random_string(1, BASE62, source) == "5"
    assert source.given == 9


def test_digits():
    """10 digits: the cut-off is 250, so 250 is refused and 249 gives 9"""
    source = Bytes([250, 249, 12])
    assert random_string(2, "0123456789", source) == "92"


def test_no_rejection_for_powers_of_two():
    """16 hex digits: every byte is kept"""
    source = Bytes([255, 16, 1])
    assert random_string(3, "0123456789abcdef", source) == "f01"
    assert source.given == 3


def test_empty_and_bad_input():
    """length 0 is empty; 1 or 257 characters, a repeated one, and lengths -1 and 2.5 are refused"""
    assert random_string(0, BASE62, Bytes([])) == ""
    wide = "".join(chr(0x100 + i) for i in range(257))
    for length, alphabet in [(4, "a"), (4, wide), (4, "aab"), (-1, BASE62), (2.5, BASE62)]:
        assert raises_value_error(lambda: random_string(length, alphabet, Bytes([1] * 8))), f"{length}, {alphabet[:8]!r}"

JavaScript · Starter code · random_string.mjs

/** length characters from alphabet, each equally likely. source(bytes) fills a Uint8Array. */
export function randomString(length, alphabet, source) {
  let out = '';
  for (let i = 0; i < length; i++) {
    const bytes = new Uint8Array(1);
    source(bytes);
    out += alphabet[bytes[0] % alphabet.length]; // modulo bias
  }
  return out;
}
The sample tests · random_string.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { randomString } from './random_string.mjs';

const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

// A test source that hands out the given bytes, one per call, and counts them.
function bytesSource(list) {
  const state = { given: 0 };
  state.fill = (bytes) => {
    if (bytes.length !== 1) throw new Error('ask for one byte at a time');
    if (state.given >= list.length) throw new Error('the source ran out: too many bytes were asked for');
    bytes[0] = list[state.given++];
    return bytes;
  };
  return state;
}

test('the 256 byte values, shuffled: 248 Base62 characters, each exactly 4 times, and all 256 bytes used', () => {
  const source = bytesSource(Array.from({ length: 256 }, (_, b) => (3 * b + 7) % 256));
  const counts = new Map();
  for (const c of randomString(248, BASE62, source.fill)) counts.set(c, (counts.get(c) ?? 0) + 1);
  assert.equal(counts.size, 62);
  assert.deepEqual([...new Set(counts.values())], [4]);
  assert.equal(source.given, 256);
});

test('Base62: the bytes 248-255 are refused, then 5 gives the character 5', () => {
  const source = bytesSource([248, 249, 250, 251, 252, 253, 254, 255, 5]);
  assert.equal(randomString(1, BASE62, source.fill), '5');
  assert.equal(source.given, 9);
});

test('10 digits: the cut-off is 250, so 250 is refused and 249 gives 9', () => {
  assert.equal(randomString(2, '0123456789', bytesSource([250, 249, 12]).fill), '92');
});

test('16 hex digits: every byte is kept', () => {
  const source = bytesSource([255, 16, 1]);
  assert.equal(randomString(3, '0123456789abcdef', source.fill), 'f01');
  assert.equal(source.given, 3);
});

test('length 0 is empty; 1 or 257 characters, a repeated one, and lengths -1 and 2.5 are refused', () => {
  assert.equal(randomString(0, BASE62, bytesSource([]).fill), '');
  const wide = Array.from({ length: 257 }, (_, i) => String.fromCharCode(0x100 + i)).join('');
  for (const [length, alphabet] of [[4, 'a'], [4, wide], [4, 'aab'], [-1, BASE62], [2.5, BASE62]]) {
    assert.throws(() => randomString(length, alphabet, bytesSource(Array(8).fill(1)).fill), `${length}, ${alphabet.slice(0, 8)}`);
  }
});
A hint

The cut-off is 256 - 256 % size: 250 for 10 characters, 248 for 62, 256 for 16 or 64, where nothing is rejected. Loop until the string is long enough, and append a character only when the byte you drew is below the cut-off.

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.

UUID Generator Generate version 4 and version 7 UUIDs and find the version digit, the variant and the timestamp described above.

Then practise across the five ideas: the first questions go to your weakest one, right answers earn harder ones, 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.

  1. Question 1 of 14 How many of the 128 bits of a version 4 UUID are random?

    Type a number.

    Show the answer: Question 1 of 14

    Answer: 122 bits

    4 bits hold the version and 2 the variant, so 128 − 6 = 122 are random, as RFC 9562 section 5.4 specifies and the program counts.

  2. Question 2 of 14 A ledger table gets millions of inserts a day and its primary key appears in no URL. Which key fits best?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: A version 7 UUID, which sorts by creation time

    For a key that names rows and is never a secret, time-ordered version 7 UUIDs keep inserts in order. The moment a value grants access, it needs a random token instead.

  3. Question 3 of 14 A design document proposes API keys of the form "key_" + uuid7(). What do you recommend?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Keep the prefix but use 16 or more bytes from the secure generator, such as "key_" + 22 Base64url characters

    An API key is a secret: 16 or more bytes from crypto.getRandomValues or secrets.token_urlsafe. A readable prefix is fine and useful, but no UUID version belongs in the random part.

  4. Question 4 of 14 How many hex characters does a 128-bit token need?

    Type a number.

    Show the answer: Question 4 of 14

    Answer: 32 characters

    Each hex character carries 4 bits, so 128 ÷ 4 = 32 characters, the 32 of the program's table.

  5. Question 5 of 14 A product wants 128-bit tokens that people can copy by hand without confusing 0 and O. Which format is shortest and still carries 128 bits?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: 22 Base58 characters

    The length is the smallest L with 58^L ≥ 2¹²⁸, which is 22. The larger alphabet makes the token shorter, and it carries no more entropy than its length times log₂ 58.

  6. Question 6 of 14 A token is 24 characters long, each drawn uniformly from 62 letters and digits, but the first 4 characters are always "tok_". How many bits of entropy does it carry?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: About 119 bits, from the 20 random characters

    A prefix is public, so only the 20 random characters count: 20 × 5.954 ≈ 119 bits. Making the random part 22 characters gives 131 bits.

  7. Question 7 of 14 With digit = byte % 10 on a uniform random byte, what is the chance of the digit 0?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: 26 in 256, about 10.16 %

    0 to 5 each come from 26 of the 256 byte values and 6 to 9 from 25, which is why the million-draw count showed about 101,500 against 97,800.

  8. Question 8 of 14 The million-draw run scored byte % 10 at a chi-squared of 319.9 with 9 degrees of freedom. What does that tell you?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: The digits are far from uniform; a fair source exceeds 16.92 only 5 % of the time

    A chi-squared score compares observed with expected counts. With 9 degrees of freedom, 16.92 is the score a fair source exceeds only 5 % of the time, so 319.9 says the digits are biased while 13.8 is what fair digits look like.

  9. Question 9 of 14 HOTP reduces a 31-bit number modulo 10⁶ and RFC 4226 accepts the resulting bias. Why is that acceptable there, while byte % 62 in a token generator is not?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Over 2³¹ values the bias is tiny, a best guess of 1.00024045 in a million, and the verifier limits guesses; one byte for 62 values gives a 25 % edge

    A reduction of an m-value range to n values favours some outputs by at most one value in floor(m/n). For HOTP that is 1 in 2,147, a factor of 1.00047 at worst; for one byte and 62 characters it is 1 in 4, a factor of 1.25.

  10. Question 10 of 14 With the masked-bits form of rejection sampling and a bound of 1,000, what share of tries is accepted? Give a fraction with three decimal places.

    Type a number.

    A hint

    999 needs 10 bits, and 10 bits give 1,024 values.

    Show the answer: Question 10 of 14

    Answer: 0.977 (a fraction) (anything from 0.976 to 0.978 counts)

    Keeping 10 bits gives values 0 to 1,023, of which 0 to 999 are accepted: 1,000 ÷ 1,024 = 0.977. On average a number costs 1.024 tries.

  11. Question 11 of 14 A teammate says: "Take 4 random bytes as a 32-bit number and use % 10. The bias is too small to matter." What is the best answer?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: The bias shrinks to about 1 in 430 million but is still there; rejection sampling or crypto.randomInt removes it at almost no cost, and leaves nothing to argue about later

    A larger range makes modulo bias smaller but never zero unless the size divides the range. Rejection sampling, or crypto.randomInt and secrets.randbelow, give exact uniformity, so there is no reason to keep a known bias.

  12. Question 12 of 14 Which question does the birthday bound answer?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: How likely it is that two of n random identifiers come out the same

    The birthday bound, about 1 − e^(−n²/2^(b+1)), is about collisions among identifiers. Guessing has its own sum: live values times guesses over 2^b.

  13. Question 13 of 14 A service has a billion live 64-bit session tokens, and an attacker can make a million guesses a day without being blocked. What did the lesson's program find for a year?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: About a 2 % chance of hitting some session

    The chance of a hit is about guesses × live ÷ 2^b = 3.65 × 10⁸ × 10⁹ ÷ 1.8 × 10¹⁹ ≈ 0.02. With 128 bits it falls to about 10⁻²¹, which is why secrets get 128 bits.

  14. Question 14 of 14 You must design account recovery codes that people type: 10 codes per account, each usable once. Which design follows the lesson?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: At least 64 bits each, say 13 Crockford Base32 characters, from the secure generator, stored hashed, with failed attempts limited

    Short secrets need rate limits, and long-lived secrets need many bits. Codes of at least 64 bits from the secure generator, kept as hashes and limited by attempts, cover both sides, and still fit on a printed sheet.

Interview questions

Warm-up (fresher to mid level): is a version 4 UUID a good password-reset token?

It is a poor choice, even though its 122 random bits are hard to guess when a secure generator made them. RFC 9562 says UUIDs must not be used as security capabilities, identifiers whose possession grants access, and that implementations should not assume they are hard to guess: the standard only recommends a secure generator for them, and version 7 puts the time inside. ASVS 5.0 requirement 11.5.1 asks for a CSPRNG and at least 128 bits for values that must not be guessed, and says UUIDs do not meet it. Use 16 or more bytes from crypto.getRandomValues or secrets.token_urlsafe(16), store only a hash of the token, make it single-use and give it a short expiry. Keep UUIDs for naming records.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Design the 6-digit code that a sign-up flow emails to confirm an address: how it is generated, how many attempts are allowed, how long it lives and how it is stored. Give the numbers that justify each choice.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    An audit finds API keys made of 32 characters, each chosen as ALPHABET[byte % 62] from secure random bytes. How serious is it? Quantify the effect, decide whether existing keys must be rotated, and fix the generator.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What do version 4 and version 7 UUIDs contain?

    Both are 128 bits with 4 bits of version and 2 of variant (the 13th hex digit is the version). Version 4 has 122 random bits. Version 7 starts with the Unix time in milliseconds, so new ones sort after old ones and database inserts stay close together, and has at most 74 random bits, random or a counter.

  2. Why is a version 4 UUID the wrong password-reset token?

    Under RFC 9562 a UUID may never act as a security capability, a value that grants access to whoever holds it, and nobody should assume UUIDs are hard to guess. ASVS 5.0 requirement 11.5.1 says UUIDs fall short of its rule for values nobody may guess: a CSPRNG and 128 bits or more. Version 7 also reveals when a record was created. Use a random token; keep UUIDs for naming records.

  3. How many bits does a token carry, and how long must it be for 128 bits?

    With every character uniform and independent, length × log2(alphabet size): hex carries 4 bits a character, Base64url 6, a decimal digit 3.32. For 128 bits use 32 hex, 26 Crockford Base32, 22 Base58, Base62 or Base64url characters, or 39 digits. Thirty-two decimal digits carry only 106 bits, so count bits, not characters.

  4. What is modulo bias, and how big is it for byte % 10 and byte % 62?

    A byte has 256 values and 256 = 25 × 10 + 6, so the bytes 250 to 255 land on 0 to 5: those digits come from 26 bytes each, 6 to 9 from 25, and each of 0 to 5 is 4 % more likely. For 62 characters 8 are 25 % more likely. Sizes that divide 256, such as 16 and 64, have no bias.

  5. How does rejection sampling remove modulo bias?

    Use only values that split evenly: keep a byte only if it is below 250, the largest multiple of 10 that fits in a byte, and draw again otherwise. Every digit then comes from exactly 25 bytes. The result is exactly uniform and the expected number of draws stays below 2; the seeded run threw away 2.32 % of bytes and scored chi-squared 13.8 against 319.9 for byte % 10.

  6. Which ready-made calls give unbiased random numbers and picks?

    Node's crypto.randomInt(min, max), unbiased for ranges below 2^48, and Python's secrets.randbelow(n) and secrets.choice(seq). Write your own only where no such call exists, such as picking characters from your own alphabet, and test it on every byte value.

  7. How do a collision and a guess differ as risks for random identifiers?

    A collision, two identifiers that are equal, matters for uniqueness and grows with the square of the number made (birthday bound). A guess, hitting any live identifier, matters for security and grows with live values times guesses. A trillion version 4 UUIDs collide with a chance of 9.4 × 10^-14, so they name records well; secrets get 128 bits even where 64 would be unique enough.

  8. What keeps a six-digit one-time code safe, given that it carries only about 20 bits?

    The verifier, not the code: it throttles failed attempts and the code expires. NIST SP 800-63B-4 caps consecutive failures at 100, requires rate limiting for any such secret under 64 bits, and gives an out-of-band code at most 10 minutes. With 100 tries the chance of hitting a 6-digit code is 1 in 10,000; with unlimited tries it is certain.

Key takeaways

  • A UUID is for uniqueness: version 4 has 122 random bits, version 7 starts with the time in milliseconds, and RFC 9562 forbids using either as a value whose possession grants access.
  • A token of length characters from an alphabet of size carries length × log₂(size) bits: 128 bits take 32 hex, 26 Base32 or 22 Base64url characters.
  • byte % n is biased unless n divides 256: byte % 10 made 0 to 5 4 % more likely, and byte % 62 makes 8 characters 25 % more likely.
  • Rejection sampling removes the bias exactly by drawing again for values above the largest multiple of n, and crypto.randomInt, secrets.randbelow and secrets.choice already do it.
  • Collisions grow with the square of the number of identifiers and guesses with the number of live values times the tries, so secrets get 128 bits and short codes get rate limits and a short life.

Terms in this lesson

entropy (of a token)
The number of bits an attacker has to guess. A token of L characters, each drawn uniformly and independently from an alphabet of n, carries L × log2(n) bits; a fixed part such as a prefix carries none.
modulo bias
The unevenness left by reducing random values with %: byte % 10 gives 0 to 5 from 26 of the 256 byte values each and 6 to 9 from 25. It disappears only when the size divides the range.
rejection sampling
Drawing again whenever a random value falls at or above the largest multiple of the size you need, so that every result comes from the same number of values and is exactly uniform.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.