Skip to Content
TokenEntropy and Collisions

Ahead of the release

npm install @evanion/token gives you 0.1.0. These pages document main, which has changes that release does not.

Entropy and collisions

length counts the check character, so usable entropy is (length - 1) * log2(n). At the defaults — length: 8, n: 32 — that is 35 bits, and entropyBits reports it:

const token = createToken(); token.entropyBits; // 35 createToken({ length: 12 }).entropyBits; // 55 createToken({ length: 13, chunkSize: 13 }).entropyBits; // 60

Read it as a collision budget

35 bits is 34,359,738,368 values. The birthday bound, not the size of the space, is what decides when two codes collide:

Issued codesChance at least two collide, at 35 bits
10,000about 0.15%
100,000about 13%
218,000about 50%
1,000,000effectively certain

The 50% point is roughly 1.18 × sqrt(2^bits). Five more characters adds 25 more bits, which moves it from 218,000 to about 1.3 billion:

lengthbits50% collision at
6256,800
835218,000
10457,000,000
13601,300,000,000

Pick length from the row above your expected volume, with room for the volume you will have in three years.

Uniqueness is a database constraint

generate never retries and never checks for collisions. It cannot: the package has no idea what you have issued.

So put a unique index on the column, and handle the conflict:

async function issue(): Promise<string> { for (let attempt = 0; attempt < 5; attempt++) { const { value, body } = token.generate({ prefix: 'ORD' }); try { await db.orders.insert({ code: body }); return value; } catch (error) { if (!isUniqueViolation(error)) throw error; } } throw new Error('could not allocate an order code'); }

Index on body, not on value. body is the unchunked, prefix-free, case-folded form, and it is what validate hands back for a lookup — a caller who typed the code without its separators has to find the same row.

The retry loop belongs here because the database is the only participant that knows what has been issued.

This is not a guess-resistance budget

35 bits is not enough to stop an attacker enumerating codes. If the code is a capability — a password reset link, a download token, anything where knowing the string grants access — this is the wrong package. Use a 128-bit random value and do not make it human-readable, because nobody types it.

What this package is for is a code a person handles: an order reference, a gift card, a support ticket, a table number. Those are looked up, not authorised, and the thing you are defending against is a transcription error.

If a human-readable code does gate something, rate-limit the lookup and bind it to a second factor the guesser does not have — the email address it was issued to, for instance.

Shortening the code instead

Two ways to spend fewer characters for the same entropy, neither free:

Raise n. 64 characters is 6 bits each instead of 5, but the alphabet rules forbid uppercase, which is where the extra 32 would have to come from.

Drop the check character. length: 8 with no check would be 40 bits rather than 35, and a mistyped code would then cost a database lookup and an unhelpful “not found”. There is no option to do it.

Last updated on