5 mistakes developers make when generating random tokens

# security# javascript# python# webdev
5 mistakes developers make when generating random tokensdummyipsum

Random strings end up everywhere: API keys, password reset links, invite codes, session IDs, file...

Random strings end up everywhere: API keys, password reset links, invite codes, session IDs, file names. Most of the time the code that generates them is three lines long and nobody looks at it again. Here are five mistakes I keep running into, with a fix for each in the languages most of us use.

1. Using Math.random()

// Don't do this for anything secret
const token = Math.random().toString(36).slice(2);
Enter fullscreen mode Exit fullscreen mode

Math.random() is fast and fine for shuffling a playlist, but it is not a cryptographically secure generator. MDN says it plainly: do not use it for anything related to security. V8 uses xorshift128+, and given a handful of outputs, its internal state can be recovered and the next values predicted.

The one-liner is also short and uneven: in a quick run of 100,000 calls the result was anywhere from 8 to 14 characters long, usually 11.

Use the secure generator your platform already ships:

// Browser and Node.js 19+
crypto.getRandomValues(new Uint8Array(32));

// Node.js
require('crypto').randomBytes(32).toString('hex');
Enter fullscreen mode Exit fullscreen mode
import secrets
secrets.token_hex(32)  # 64 hex characters
Enter fullscreen mode Exit fullscreen mode
bin2hex(random_bytes(32)); // 64 hex characters
Enter fullscreen mode Exit fullscreen mode
openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

2. Modulo bias

So you switched to a secure source and want letters and numbers only:

const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = crypto.getRandomValues(new Uint8Array(32));
const token = Array.from(bytes, (b) => alphabet[b % 62]).join('');
Enter fullscreen mode Exit fullscreen mode

It looks random, but it is not uniform. A byte has 256 values, and 256 is not divisible by 62: 256 = 4 × 62 + 8. The first 8 characters of the alphabet (A to H) get 5 chances out of 256, all the others get 4. That makes them 25% more likely. I ran it over 6.5 million bytes to be sure: A came up 128,293 times, z 102,634 times, a ratio of 1.25.

For a 32-character token the loss of entropy is small, but it is free to avoid. Throw away the values that fall into the uneven tail (rejection sampling):

function randomString(length, alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
  const limit = 2 ** 32 - (2 ** 32 % alphabet.length);
  let value = '';
  while (value.length < length) {
    const [n] = crypto.getRandomValues(new Uint32Array(1));
    if (n < limit) value += alphabet[n % alphabet.length];
  }
  return value;
}
Enter fullscreen mode Exit fullscreen mode

In Python you don't have to think about it, secrets.choice() is already uniform:

import secrets, string
alphabet = string.ascii_letters + string.digits
''.join(secrets.choice(alphabet) for _ in range(32))
Enter fullscreen mode Exit fullscreen mode

3. Picking the length by feel

"16 characters sounds long enough" is how weak tokens happen. The number that matters is bits of randomness:

bits = length × log2(alphabet size)
Enter fullscreen mode Exit fullscreen mode

For letters and numbers (62 characters, about 5.95 bits each):

Length Bits
8 47.6
16 95.3
22 131
32 190.5
43 256

Hex carries exactly 4 bits per character, so 64 hex characters are 256 bits.

OWASP asks for at least 64 bits of entropy for session IDs, and 128 bits is a common target for anything long-lived like API keys or reset tokens. For a JWT HS256 secret, match the hash size: 256 bits, which is 43 alphanumeric or 64 hex characters.

4. Standard Base64 in URLs

require('crypto').randomBytes(32).toString('base64');
// 'MNRSbqb16Oi8sGwLu+bW2yCcsjhUoYPhNemI1Sl3m/w='
Enter fullscreen mode Exit fullscreen mode

Standard Base64 uses +, / and =. Put that token in a query string and + turns into a space when it is decoded, / collides with paths and = needs escaping. The reset link works in your tests and fails for the one user whose token happened to contain a +.

Use the URL-safe variant, which swaps in - and _ and drops the padding:

require('crypto').randomBytes(32).toString('base64url'); // 43 characters
Enter fullscreen mode Exit fullscreen mode
secrets.token_urlsafe(32)  # 43 characters
Enter fullscreen mode Exit fullscreen mode
rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
Enter fullscreen mode Exit fullscreen mode

If you are curious why a space becomes %20 in one place and + in another, I wrote that up separately: https://www.dummyipsum.com/blog/url-encoding-why-spaces-become-20

5. Using a UUID as a secret

UUIDs are great IDs, and that is the problem: they look random enough to be used as secrets too.

  • UUID v4 has 122 random bits, but the spec only recommends a secure generator, and libraries differ. RFC 9562 is blunt about it: implementations SHOULD NOT assume UUIDs are hard to guess, and they MUST NOT be used as security capabilities (identifiers whose mere possession grants access).
  • UUID v7, the popular time-ordered one, starts with a 48-bit millisecond timestamp. Only 74 bits are random, and anyone holding one knows when it was created.
  • v1 contains a timestamp and historically the MAC address.

Use UUIDs for primary keys and correlation IDs. For anything that grants access (a reset link, an invite, an API key), generate a token from a secure source as in the sections above.

Bonus: codes that humans type

Invite and voucher codes get read off paper and typed on phones. Leave out the characters people confuse: 0 and O, 1, l and I. Use uppercase letters and digits only, and add a few characters to make up for the smaller alphabet. Uppercase and digits without 0, O, 1 and I leave 32 characters, so ten of them give you 50 bits, plenty for a code that is rate-limited on the server.

A quick way to get one

When I just need a token for a .env file or test data and don't want to open a REPL, I use the generator on my site. It runs in the browser with crypto.getRandomValues, does the rejection sampling from section 2, and handles hex, look-alike-free codes, prefixes and up to 1,000 strings at once: https://www.dummyipsum.com/random-string-generator

What is the worst token generation code you have seen in production?