Skip to content

CWE-331: Insufficient Entropy - JavaScript/Node.js

Overview

Insufficient entropy is a question about the number of unpredictable bits in a value, not about which function produced it. In JavaScript it shows up in two shapes. The first is an output that is simply too small: Math.random().toString(16).slice(2, 10) is 32 bits, crypto.randomBytes(4) is also 32 bits, and both fall to a brute-force. The second is entropy lost between generation and use - truncating a token to shorten a URL, or reducing bytes into a short alphabet with %, which both biases the distribution and shrinks the space.

There is a third that is specific to this ecosystem and easy to miss: Math.random() has no fixed output width. It returns a double, whose fraction holds at most 52 bits, so Math.random().toString(36).substring(2) carries at most 52 bits however long it looks. Measured on Node 24, that expression produces strings between 7 and 14 characters across half a million calls - a token generator whose output length varies by a factor of two is a reliable sign that the length was never chosen.

Primary Defence: Size the value against what it protects - at least 16 bytes for a token and 32 for key material - then fill it with crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser), and map it into a restricted alphabet with crypto.randomInt() rather than with %.

The related finding that the generator is not cryptographic - Math.random() in any form - is CWE-338. The two are usually reported on the same line and both are covered below.

Common Vulnerable Patterns

Using Math.random() for token generation

// VULNERABLE - Predictable token generation
function generateToken() {
    return Math.random().toString(36).substring(2);
}

Why this is vulnerable:

  • Math.random() uses an implementation-defined deterministic PRNG that is not required to be cryptographically secure.
  • The internal state can be recovered from a small number of outputs, making past and future values predictable.
  • Base-36 encoding only changes representation, not security.

Using Date.now() with Math.random() for session IDs

// VULNERABLE - Session ID generation
function generateSessionId() {
    return Date.now() + '-' + Math.random().toString(36);
}

Why this is vulnerable: Count what each half contributes. Date.now() is observable - it is roughly the time the attacker's own request was answered - so it adds zero secret bits, however many characters it puts on screen. Everything unpredictable in the session ID comes from the Math.random() half, which caps at 52 bits and in practice is far less because V8's generator state is recoverable from a handful of observed outputs. Concatenating a public value with a weak one gives you the entropy of the weak one and the appearance of both, which is the general shape: prefixing, hashing or encoding never adds bits, and a long identifier is not evidence of a large space.

Using Math.random() for API keys

// VULNERABLE - API key generation
function generateApiKey() {
    let key = '';
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    for (let i = 0; i < 32; i++) {
        key += chars[Math.floor(Math.random() * chars.length)];
    }
    return key;
}

Why this is vulnerable:

  • API keys are long-lived credentials and must be unguessable.
  • Math.random() is deterministic, so the same seed produces the same sequence.
  • If an attacker recovers the PRNG state, they can predict or forge keys.

Using Math.random() for encryption keys

// VULNERABLE - Encryption key from random
function generateKey() {
    const key = new Uint8Array(32);
    for (let i = 0; i < key.length; i++) {
        key[i] = Math.floor(Math.random() * 256);
    }
    return key;
}

Why this is vulnerable:

  • Encryption keys must be unpredictable; Math.random() is not.
  • Once an attacker infers the PRNG state, every key this loop has produced and will produce is reproducible.

Using Math.random() for CSRF tokens

// VULNERABLE - CSRF token
function generateCsrfToken() {
    return Math.random().toString(36) + Math.random().toString(36);
}

Why this is vulnerable:

  • CSRF tokens must be unguessable to prevent forged requests.
  • Math.random() outputs are predictable to attackers who learn the PRNG state.
  • Multiple calls still draw from the same predictable sequence.

Using Math.random() for password reset tokens

// VULNERABLE - Password reset token
function generateResetToken() {
    return Math.random().toString(16).slice(2, 10);
}

Why this is vulnerable:

  • Reset tokens grant account access, so they must be unguessable.
  • Eight hex characters provide at most 32 bits of entropy, which is brute-forceable.
  • The predictable PRNG makes tokens even easier to recover.

Secure Patterns

Node.js crypto.randomBytes()

const crypto = require('crypto');

/**
 * Generate cryptographically secure token (hex-encoded)
 * @param {number} bytes - Number of bytes (16 = 128 bits minimum)
 * @returns {string} Hex-encoded token
 */
function generateSecureToken(bytes = 16) {
    return crypto.randomBytes(bytes).toString('hex');
}

/**
 * Generate URL-safe base64 token
 * @param {number} bytes - Number of bytes
 * @returns {string} URL-safe base64 token
 */
function generateUrlSafeToken(bytes = 32) {
    return crypto.randomBytes(bytes)
        .toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

// Generate session ID (256 bits)
function generateSessionId() {
    return crypto.randomBytes(32).toString('hex');  // 64 hex chars
}

// Generate CSRF token (256 bits)
function generateCsrfToken() {
    return crypto.randomBytes(32).toString('base64');
}

// Generate API key (384 bits)
function generateApiKey() {
    return crypto.randomBytes(48).toString('base64');
}

// Generate password reset token (256 bits)
function generatePasswordResetToken() {
    return crypto.randomBytes(32).toString('hex');
}

/**
 * One-time code delivered out of band. NOT a token: 6 digits is
 * 6 * log2(10) = 19.9 bits, which is enumerable in the abstract. What makes
 * it usable is the attempt limit, the short expiry and single-use
 * enforcement at the call site - not the generator below.
 * @param {number} length - code length
 */
function generateOtp(length = 6) {
    let pin = '';
    for (let i = 0; i < length; i++) {
        // crypto.randomInt applies rejection sampling. Reducing a random byte
        // with % 10 does not: 256 is not a multiple of 10, so digits 0-5 come
        // up 26 times per 256 bytes against 25 times for 6-9.
        pin += crypto.randomInt(10).toString();
    }
    return pin;
}

// Generate UUID v4 - CSPRNG-backed, and permanently capped at 122 random bits
// because 6 of the 128 are fixed version and variant markers. Fine as an
// identifier; use randomBytes for key material, where the byte count is a
// number you choose.
function generateUUID() {
    return crypto.randomUUID();  // Node.js 15.6+
}

// Generate encryption key for AES-256
function generateEncryptionKey() {
    return crypto.randomBytes(32);  // 256 bits
}

// Generate nonce for AES-GCM encryption
function generateGcmNonce() {
    return crypto.randomBytes(12);  // 96 bits for GCM
}

Why this works:

  • crypto.randomBytes() uses OS-backed CSPRNG sources, not deterministic PRNGs like Math.random().
  • 256-bit tokens provide enough entropy to make guessing attacks infeasible.
  • Encoding (hex/base64url) preserves the underlying entropy while making values usable.
  • A secure source is not enough on its own when the output is a bounded number. Hex and base64 encoding preserve entropy exactly because 16 and 64 divide 256. Reducing a byte into a range that does not - a digit, a letter-and-digit alphabet - needs crypto.randomInt(), which resamples out-of-range values rather than folding them onto the low end. This is the step that turns a correct CSPRNG call into a biased credential, and it is why the PIN above does not index into a digit string.

Browser crypto.getRandomValues()

/**
 * Generate secure random bytes in browser
 * @param {number} length - Number of bytes
 * @returns {Uint8Array} Random bytes
 */
function generateRandomBytes(length) {
    const array = new Uint8Array(length);
    window.crypto.getRandomValues(array);
    return array;
}

// Convert Uint8Array to hex string
function toHex(bytes) {
    return Array.from(bytes)
        .map(b => b.toString(16).padStart(2, '0'))
        .join('');
}

// Convert Uint8Array to base64
function toBase64(bytes) {
    return btoa(String.fromCharCode.apply(null, bytes));
}

// Generate secure token in browser
function generateBrowserToken(bytes = 16) {
    const randomBytes = generateRandomBytes(bytes);
    return toHex(randomBytes);
}

// Generate CSRF token in browser
function generateBrowserCsrfToken() {
    const randomBytes = generateRandomBytes(32);
    return toBase64(randomBytes);
}

// Generate UUID v4 in browser
function generateBrowserUUID() {
    return crypto.randomUUID();  // Modern browsers
}

Why this works:

  • crypto.getRandomValues() pulls from OS CSPRNG sources via the Web Crypto API.
  • 32 bytes (256 bits) of entropy is appropriate for tokens and CSRF values, and renders as 64 hex characters or 43 base64url characters - so a field sized for 32 characters holds half the value, not all of it.
  • crypto.randomUUID() uses the same secure source and produces a v4 UUID, which carries 122 random bits regardless.

Complete encryption example with secure randomness

const crypto = require('crypto');

class SecureEncryption {
    // Generate AES-256 key
    static generateKey() {
        return crypto.randomBytes(32);  // 256 bits
    }

    /**
     * Encrypt with AES-256-GCM (authenticated encryption)
     * @param {Buffer} plaintext - Data to encrypt
     * @param {Buffer} key - 32-byte encryption key
     * @returns {Object} {nonce, authTag, ciphertext}
     */
    static encrypt(plaintext, key) {
        // Generate secure nonce (96 bits for GCM)
        const nonce = crypto.randomBytes(12);

        // Create cipher
        const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);

        // Encrypt
        const ciphertext = Buffer.concat([
            cipher.update(plaintext),
            cipher.final()
        ]);

        // Get authentication tag
        const authTag = cipher.getAuthTag();

        return { nonce, authTag, ciphertext };
    }

    // Decrypt AES-256-GCM ciphertext
    static decrypt(nonce, authTag, ciphertext, key) {
        const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
        decipher.setAuthTag(authTag);

        return Buffer.concat([
            decipher.update(ciphertext),
            decipher.final()
        ]);
    }
}

// Usage example
const key = SecureEncryption.generateKey();
const plaintext = Buffer.from('sensitive data');
const { nonce, authTag, ciphertext } = SecureEncryption.encrypt(plaintext, key);
const decrypted = SecureEncryption.decrypt(nonce, authTag, ciphertext, key);

console.assert(plaintext.equals(decrypted), 'Decryption failed');

Why this works:

  • Keys and nonces come from crypto.randomBytes(), providing strong entropy for AES-256-GCM.
  • AES-GCM provides authenticated encryption, so tampering is detected.
  • The 96-bit nonce size is appropriate for GCM and must never repeat with the same key.

Framework-Specific Guidance

Express.js - Session Management

const express = require('express');
const session = require('express-session');
const crypto = require('crypto');

const app = express();

// SECURE - mint the secret once, out of band:
//     node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
// then load it from the environment. Generating it at module load instead
// gives every worker process and every restart a different secret, so a
// session signed by one worker fails verification on the next.
// The length check is on the decoded BYTES, not on the hex string, which is
// twice as long - counting characters is how 16 bytes passes for 32.
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret || Buffer.from(sessionSecret, 'hex').length < 32) {
    throw new Error('SESSION_SECRET must be at least 32 bytes (64 hex characters)');
}

app.use(session({
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,
        secure: true,  // HTTPS only
        maxAge: 3600000,  // 1 hour
        sameSite: 'strict'
    },
    // Express-session uses crypto.randomBytes for session IDs automatically
    genid: (req) => {
        return crypto.randomBytes(16).toString('hex');  // Custom session ID
    }
}));

// Generate CSRF tokens
const csrfTokens = new Map();

app.use((req, res, next) => {
    if (!req.session.csrfToken) {
        req.session.csrfToken = crypto.randomBytes(32).toString('hex');
        csrfTokens.set(req.session.csrfToken, true);
    }
    next();
});

Why this works:

  • Session secrets and IDs are generated with crypto.randomBytes(), providing CSPRNG entropy.
  • The CSRF token uses 256-bit randomness, making guessing infeasible.

Next.js - API Key Generation

// pages/api/generate-key.js
import crypto from 'crypto';

export default async function handler(req, res) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed' });
    }

    // Generate secure API key
    const apiKey = crypto.randomBytes(48).toString('base64');

    // Hash for storage (don't store plaintext!)
    const hashedKey = crypto
        .createHash('sha256')
        .update(apiKey)
        .digest('hex');

    // Store hashedKey in database
    // Return apiKey to user (only shown once!)

    res.status(200).json({ apiKey });
}

Why this works:

  • API keys are generated from CSPRNG output, avoiding predictable tokens.
  • Storing only a hash limits exposure if the database is compromised.

React - Client-side UUID Generation

import React, { useState } from 'react';

function SecureIdGenerator() {
    const [id, setId] = useState('');

    const generateId = () => {
        // Modern browsers support crypto.randomUUID()
        if (window.crypto.randomUUID) {
            setId(crypto.randomUUID());
        } else {
            // Fallback for older browsers
            const bytes = new Uint8Array(16);
            window.crypto.getRandomValues(bytes);
            const hex = Array.from(bytes)
                .map(b => b.toString(16).padStart(2, '0'))
                .join('');
            setId(hex);
        }
    };

    return (
        <div>
            <button onClick={generateId}>Generate Secure ID</button>
            <p>{id}</p>
        </div>
    );
}

Why this works:

  • crypto.randomUUID() and crypto.getRandomValues() use secure randomness from the browser.
  • The fallback still uses Web Crypto, not Math.random().

JWT Security

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// SECURE - Mint once with crypto.randomBytes(32).toString('hex'), then load it
// from the secret store. HS256 keys should be at least 32 bytes (256 bits), the
// output size of the hash; the length check below is on the decoded bytes, not
// on the hex string, which is twice as long and the usual place this is
// miscounted. A secret generated at module load is a different secret in every
// replica and after every restart, so tokens stop verifying.
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'hex').length < 32) {
    throw new Error('JWT_SECRET must be at least 32 bytes (64 hex characters)');
}

function generateJwtToken(userId) {
    // Generate unique JTI (JWT ID)
    const jti = crypto.randomBytes(16).toString('hex');

    return jwt.sign(
        {
            sub: userId,
            jti: jti,
            iat: Math.floor(Date.now() / 1000),
            exp: Math.floor(Date.now() / 1000) + 3600  // 1 hour
        },
        JWT_SECRET,
        { algorithm: 'HS256' }
    );
}

function verifyJwtToken(token) {
    try {
        return jwt.verify(token, JWT_SECRET);
    } catch (err) {
        return null;
    }
}

Why this works:

  • The signing secret carries at least 256 bits, matching HS256's hash output size. Anyone who guesses it mints tokens for any account, and the guessing is offline against a token they already hold - so this is the one value on the page where the 128-bit floor is not enough.
  • The startup check counts decoded bytes rather than string length, so a 32-character hex secret (16 bytes, 128 bits) is refused instead of quietly accepted. Verified against a 16-byte secret, an absent one, and a valid 32-byte one: the first two throw at startup, the third signs and verifies.
  • The jti is 128 bits, which is about uniqueness for replay tracking rather than secrecy - it travels in the token's payload in cleartext, so entropy there buys collision resistance and nothing else.

Considerations

Not every random value is a secret. A React key, animation jitter, a cache-busting query parameter - none of these gain an attacker anything if guessed, and Math.random() is the right function for them. The question is not "is this random" but "does guessing it get someone something". Session IDs, reset tokens, API keys, CSRF tokens, OTPs, invite codes, salts and IVs all fail that test. If the value is not one of those, record the finding as a false positive with the reason.

Where the value is generated matters more than how. A token minted in the browser is guessable no matter which API produced it, because the code that produces it is public and the attacker controls the runtime. crypto.getRandomValues() is the right call for client-side work like a nonce for a local operation, but anything that grants access - session identifiers, reset tokens, API keys - must be generated on the server and sent to the client, never the reverse.

Length is a separate decision from function. crypto.randomBytes(4) is still only 32 bits. Use at least 16 bytes for tokens and 32 for key material. .toString('hex') doubles the character count, so 32 bytes renders as 64 characters - which is where 16 bytes gets mistaken for 32.

Mapping to a range needs care, and so does shortening. Using % to force a value into a numeric range biases the distribution; crypto.randomInt(min, max) rejects and retries instead. Truncating a strong value to shorten a URL discards exactly the entropy you generated it for - if the URL is too long, use a shorter encoding, not fewer bytes.

crypto.randomUUID() is CSPRNG-backed but fixed at 122 bits. Reasonable for a session identifier, short of what you want for key material, and the constraint is the UUID format rather than the generator.

Common Pitfalls

  • Modulo-reducing a CSPRNG value into a short code: Generating crypto.randomBytes(32) correctly, then deriving a PIN with parseInt(bytes.toString('hex'), 16) % 1000000 - this biases the low digits unless done with crypto.randomInt(), which applies rejection sampling. Reducing 256 bits down to a 6-digit code is fine for a short-lived OTP, but the result is sometimes mistakenly reused as a longer-lived credential.
  • Reusing a nonce across encryption calls: Generating a nonce with crypto.randomBytes(12) correctly, then reusing that same buffer across multiple createCipheriv() calls in a loop (e.g., one shared cipher-setup helper encrypting several records) - AES-GCM requires a unique nonce per encryption under the same key, and a correct CSPRNG call made once and reused still breaks that guarantee.

Additional Resources