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)
8Lowercase2637.621 seconds
8Alphanumeric6247.66.9 hours
8Full printable ASCII9552.69.2 days
12Alphanumeric6271.595,000 years
16Alphanumeric6295.31.26 × 1012 years
16Full printable ASCII95105.11.29 × 1015 years
20Alphanumeric62119.12.1 × 1019 years
32Hex16128.01.08 × 1022 years
24Full printable ASCII95157.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:

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.