Guide

Every key, token, nonce, salt and session ID must be hard to guess. "Hard to guess" means it comes from a CSPRNG, a cryptographically secure pseudo-random number generator. The operating system seeds it from hardware randomness and exposes it through getrandom() or /dev/urandom.

Concept

Entropy is how unpredictable a value is, measured in bits. A 256-bit random token needs about 2^256 guesses to find by brute force. The length you pick sets the entropy.

Why Math.random is unsafe

General-purpose generators are built for speed and statistics, not for secrecy. JavaScript's Math.random() (V8 uses xorshift128+), Python's random (Mersenne Twister) and Go's math/rand all keep a small internal state. After watching a handful of outputs, an attacker can recover that state and predict every later value. Password-reset tokens built on Math.random() have been exploited this way.

Use the CSPRNG in your language:

LanguageUseNot
Node.jscrypto.randomBytes(32), crypto.randomUUID()Math.random()
Browsercrypto.getRandomValues(new Uint8Array(32))Math.random()
Pythonsecrets.token_urlsafe(32)random.random()
Gocrypto/rand.Read(b)math/rand
JavaSecureRandomjava.util.Random

How many bits

For anything that grants access, such as session IDs, reset tokens and API keys, use at least 128 bits. 256 bits, or 32 bytes, costs nothing extra.

Work the numbers for 128 bits. Say an attacker makes a billion guesses per second against a system with a million live tokens:

2^128 / (10^6 tokens × 10^9 guesses per second) ≈ 3.4 × 10^23 seconds ≈ 10^16 years

Guessing is hopeless at that size. The weak point is never the length. It's a predictable generator.

Walking through it

TypeScript
import { randomBytes, createHash } from "node:crypto"

const token = randomBytes(32).toString("base64url")                 // send to the user once
const tokenHash = createHash("sha256").update(token).digest("hex")  // store only this

console.log(token.length, tokenHash.length)
// 43 64

Store the hash, not the token. A database leak then doesn't leak usable tokens. A fast hash is fine here, unlike for passwords, because a 256-bit random value can't be brute-forced. See password hashing for why passwords are different.

A note on IDs. UUIDv4 holds 122 random bits from a CSPRNG, so it's unguessable in practice. UUIDv7 starts with a timestamp, so it's partly predictable. Use IDs as identifiers, never as secrets, and never use an unguessable ID in place of an authorization check.

Common mistakes

  • Using Math.random(), random.random() or math/rand for anything that grants access.
  • Storing the raw token, so a database read gives an attacker working tokens.
  • Treating a sequential or time-based ID as a secret.

Check yourself

  • Why isn't a 256-bit token made with Math.random() safe? Answer: the generator's state is small, so an attacker can recover it from a few outputs and predict the rest.
  • Why store the SHA-256 of a reset token instead of the token? Answer: a database leak then exposes only hashes, and a 256-bit random token can't be brute-forced from its hash.

Key takeaways

  • Secrets come from a CSPRNG, such as crypto.randomBytes, Python's secrets or Go's crypto/rand.
  • Use at least 128 bits for access tokens. 256 bits costs nothing extra.
  • Store a hash of each token, so a leaked table is useless.
  • IDs identify things. They don't authorize anything.

Further learning

Next, see password hashing, the case where random salts matter and fast hashes don't work.