Not all randomness is created equal. When you generate a password reset token, an API key, or a session identifier, the randomness backing that value must be cryptographically secure — meaning an attacker cannot predict future outputs even if they observe previous ones. JavaScript offers two very different sources of randomness, and choosing the wrong one can leave your application wide open to attack. This guide explains the difference, shows you how to generate secure tokens entirely in the browser, and gives you the entropy math to choose the right length and character set for any use case.
Why Math.random() Is Unsafe for Secrets
Math.random() returns a floating-point number between 0 and 1. It is fast, convenient, and built into every JavaScript engine. It is also completely unsuitable for generating secrets.
The reason is architectural. Math.random() is backed by a pseudo-random number generator (PRNG) — an algorithm that produces a deterministic sequence of numbers from an initial seed. In V8 (the engine behind Chrome and Node.js), the implementation uses an algorithm called xorshift128+. This algorithm is designed for speed, not security: given a handful of observed outputs, an attacker can reconstruct the internal state and predict every future value.
Researchers have demonstrated this in practice. With as few as five consecutive outputs from Math.random(), it is possible to reverse-engineer the xorshift128+ state using a Z3 SMT solver. Once the state is recovered, every subsequent call to Math.random() is predictable. If your session tokens are built on Math.random(), an attacker who intercepts a few of them can forge the rest.
// DO NOT use this for security-sensitive values
function insecureToken(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Output looks random but is deterministic and predictable
The core problem is that Math.random() does not draw from an operating-system entropy source. It has no access to hardware noise, interrupt timing, or any of the environmental unpredictability that makes true cryptographic randomness possible. For non-security purposes like shuffling a playlist or positioning UI elements, Math.random() is fine. For anything involving secrets, it must be avoided.
crypto.getRandomValues() — The Right Tool
The Web Crypto API provides crypto.getRandomValues(), which fills a typed array with cryptographically strong random values. Under the hood, it calls the operating system's CSPRNG (cryptographically secure pseudo-random number generator): /dev/urandom on Linux, BCryptGenRandom on Windows, and SecRandomCopyBytes on macOS. These sources gather entropy from hardware interrupts, disk timing, mouse movements, and other unpredictable physical events.
The API is straightforward. You pass a Uint8Array (or any integer typed array) and it fills every element with a random value:
// Generate 16 random bytes const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); // bytes is now filled with cryptographically secure random values
To convert raw bytes into a usable string, you typically encode them as hexadecimal or Base64:
// Convert to hex string
function randomHex(byteLength) {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
console.log(randomHex(16));
// "a3f7c91b0e4d28f6719c5ab2e308d4f1"
// Convert to Base64 string
function randomBase64(byteLength) {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
console.log(randomBase64(32));
// "k7xR2vNmQ1p-fW8sYjLdHnTcA5bEgIu0rKoXhZwM3y"
A common pattern for generating tokens from a custom character set is to use the rejection sampling technique to avoid modulo bias. When you compute randomByte % charsetLength, some characters are slightly more likely than others unless the charset length evenly divides 256. For most practical purposes (charsets of 62 or 64 characters) the bias is negligible, but for maximum correctness:
function secureToken(length, charset) {
const maxValid = 256 - (256 % charset.length);
const bytes = new Uint8Array(length * 2); // over-allocate
crypto.getRandomValues(bytes);
let result = '';
for (let i = 0; i < bytes.length && result.length < length; i++) {
if (bytes[i] < maxValid) {
result += charset[bytes[i] % charset.length];
}
}
return result;
}
crypto.getRandomValues() is supported in all modern browsers, Node.js (via the crypto module), Deno, and web workers. It has no limit on the number of calls, though a single invocation can fill at most 65,536 bytes.
crypto.randomUUID()
If you need a standard version-4 UUID, modern browsers offer crypto.randomUUID() as a one-liner. It returns a string in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx, where x is a random hex digit and y is one of 8, 9, a, or b.
const uuid = crypto.randomUUID(); console.log(uuid); // "550e8400-e29b-41d4-a716-446655440000"
A v4 UUID contains 122 bits of randomness (128 bits total minus 6 bits reserved for version and variant fields). That gives roughly 5.3 × 1036 possible values — more than enough to avoid collisions in any practical system.
crypto.randomUUID() is supported in Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+, and Node.js 19+. For older environments, you can polyfill it using crypto.getRandomValues():
function randomUUID() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
const hex = Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join('')
].join('-');
}
Password Entropy Explained
The strength of a randomly generated password is measured in bits of entropy. The formula is simple:
entropy = log2(charset_size ^ length)
= length * log2(charset_size)
Higher entropy means more possible combinations, which means more time for an attacker to brute-force. The following table shows entropy and estimated crack times for different configurations, assuming an attacker can test 10 billion guesses per second (a realistic rate for offline attacks using modern GPUs):
| Length | Charset | Pool Size | Entropy (bits) | Crack Time (10B/sec) |
|---|---|---|---|---|
| 8 | Lowercase | 26 | 37.6 | 21 seconds |
| 8 | Alphanumeric | 62 | 47.6 | 6.9 hours |
| 8 | Full printable ASCII | 95 | 52.6 | 9.2 days |
| 12 | Alphanumeric | 62 | 71.5 | 95,000 years |
| 16 | Alphanumeric | 62 | 95.3 | 1.26 × 1012 years |
| 16 | Full printable ASCII | 95 | 105.1 | 1.29 × 1015 years |
| 20 | Alphanumeric | 62 | 119.1 | 2.1 × 1019 years |
| 32 | Hex | 16 | 128.0 | 1.08 × 1022 years |
| 24 | Full printable ASCII | 95 | 157.7 | > age of universe |
Key takeaways: an 8-character lowercase password is cracked in seconds. A 12-character alphanumeric password survives thousands of years. For API keys and session tokens, aim for at least 128 bits of entropy — that means 32 hex characters, 22 Base64 characters, or 22 alphanumeric characters.
Character Set Choices
The character set you choose affects entropy density (bits per character), readability, and compatibility. Here are the common options and their tradeoffs:
- Hex (0-9, a-f): 4 bits per character. Simple, URL-safe, no encoding issues. Widely used for hash digests and cryptographic keys. Downside: you need twice as many characters as bytes of entropy, so tokens are long.
- Alphanumeric (A-Z, a-z, 0-9): 5.95 bits per character. Good balance of density and readability. Safe in URLs, filenames, and databases without escaping. The most popular choice for user-facing tokens and passwords.
- Base64 / URL-safe Base64 (A-Z, a-z, 0-9, -, _): 6 bits per character. Maximum density without special characters. Standard Base64 uses
+and/which require URL encoding; the URL-safe variant replaces them with-and_. Ideal for compact tokens in headers and cookies. - Full printable ASCII (33-126): 6.57 bits per character. Highest density per character but includes symbols like
&,<,", and'that cause problems in HTML, URLs, SQL, and shell commands. Best reserved for passwords stored in a password manager, where copy-paste is the only interface.
For most applications, alphanumeric or URL-safe Base64 is the right default. You get strong entropy density without worrying about encoding or escaping issues.
Practical Examples
Here are ready-to-use functions for common token generation scenarios, all using crypto.getRandomValues():
API Key (32 alphanumeric characters, ~190 bits entropy):
function generateApiKey() {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map(b => charset[b % charset.length])
.join('');
}
console.log(generateApiKey());
// "kT9mR2xLpN7vYcWfBj4qAs6Dh1Ue0ZoG"
Session Token (64 hex characters, 256 bits entropy):
function generateSessionToken() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
console.log(generateSessionToken());
// "a4c7e91f3b28d0564f17c2a8e903b6d15e8f0a7c2d49b3e61f85c0a7d23e4b9f"
One-Time Password / OTP (6-digit numeric code):
function generateOTP(length = 6) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map(b => (b % 10).toString())
.join('');
}
console.log(generateOTP());
// "849173"
Cryptographic Nonce (URL-safe Base64, 24 bytes / 192 bits):
function generateNonce() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
console.log(generateNonce());
// "r7Kx2vNm-Q1pfW8sYj_LdHnTcA5bEgIu"
Each of these functions runs entirely in the browser. No network request is made, no server sees the generated value, and the cryptographic randomness comes directly from your operating system's entropy pool. These are production-ready patterns you can drop into any web application.