Skip to content

CWE-330: Use of Insufficiently Random Values - JavaScript/Node.js

Overview

Weak random number generation in JavaScript/Node.js occurs when developers use Math.random() for security-sensitive operations like generating session tokens, CSRF tokens, API keys, or cryptographic material. Math.random() is a pseudo-random number generator (PRNG) designed for general purposes like games and simulations, not security. Its algorithm and seeding are implementation-defined and not intended to resist prediction.

Primary Defence: Use crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser) for all security-sensitive random value generation.

Common Vulnerable Patterns

Math.random() for Session IDs

// VULNERABLE - Predictable session ID
function generateSessionId() {
    return Math.floor(Math.random() * 1000000000).toString();
}

// Session ID like "847293561" looks random but is predictable
// Math.random() is implementation-defined and not cryptographically secure

Why this is vulnerable: Math.random() is not specified as a cryptographic RNG. Implementations are designed for general-purpose randomness, so an attacker may recover the generator state and predict future values.

Math.random() for Reset Tokens

// VULNERABLE - Predictable password reset token
function generateResetToken() {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let token = '';
    for (let i = 0; i < 32; i++) {
        token += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return token;
}

// Token like "aB7xQ9mK2pLr5tYw..." looks secure but isn't

Why this is vulnerable: Each character selection uses Math.random(). Predictable sequence = predictable token.

Date.now() as Seed

// VULNERABLE - Using timestamp as randomness
function generateToken() {
    const timestamp = Date.now();
    return timestamp.toString(36) + Math.random().toString(36).substring(2);
}

// Token like "l8x9k3a7b2c" partially based on timestamp
// Attacker knowing approximate time can narrow possibilities

Why this is vulnerable: Timestamps are observable and predictable within a narrow window. Math.random() adds non-cryptographic randomness and does not make the token suitable for security use.

UUID v4 Polyfill with Math.random()

// VULNERABLE - Custom UUID implementation
function uuidv4() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        const r = Math.random() * 16 | 0;  // WEAK
        const v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
}

// UUID like "f47ac10b-58cc-4372-a567-0e02b2c3d479" but predictable

Why this is vulnerable: The UUID has the right shape, but every hex digit comes from Math.random() rather than a cryptographic source, so it is as predictable as the PRNG behind it.

Lottery/Gaming RNG

// VULNERABLE - Gaming random number
function rollDice() {
    return Math.floor(Math.random() * 6) + 1;  // 1-6
}

function drawLotteryNumber() {
    return Math.floor(Math.random() * 100) + 1;  // 1-100
}

// If money/rewards involved, users can predict outcomes

Why this is vulnerable: If money or rewards are involved, game outcomes need adversarially unpredictable randomness. Math.random() is not specified to provide that property, so observable outputs or implementation details may give players an unfair advantage.

Weak Encryption IV/Nonce

const crypto = require('crypto');

// VULNERABLE - Predictable IV
function encrypt(data, key) {
    // WRONG way to generate IV
    const iv = Buffer.from(Math.random().toString(36).substring(2, 18).padEnd(16, '0'));

    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    let encrypted = cipher.update(data, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    return {encrypted, iv: iv.toString('hex')};
}

// CBC IV must be unpredictable for security

Why this is vulnerable: AES-CBC requires an unpredictable IV; predictable IVs can enable chosen-plaintext attacks. AEAD modes such as AES-GCM have different requirements: the nonce must be unique under the same key, and random 96-bit nonces are a common way to achieve that.

CSRF Token with Math.random()

// VULNERABLE - Weak CSRF token
function generateCsrfToken(req, res) {
    const token = Math.random().toString(36).substring(2);  // WEAK
    req.session.csrfToken = token;
    return token;
}

// Token like "a7b3c9d2e5" - predictable

Why this is vulnerable: An attacker who can predict the token passes the CSRF check.

API Key Generation

// VULNERABLE - Weak API key
function generateApiKey() {
    const prefix = 'sk_';
    const random = Math.random().toString(36).substring(2) + 
                   Math.random().toString(36).substring(2);
    return prefix + random;
}

// API key like "sk_a7b3c9d2e5f1g8h4" - predictable

Why this is vulnerable: API keys grant access. Predictable keys = unauthorized access.

Math.random() for Any Security Purpose

// VULNERABLE - All insecure
const sessionId = Math.floor(Math.random() * 1000000);
const token = Math.random().toString(36);
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    return (Math.random() * 16 | 0).toString(16);  // WEAK
});

Why this is vulnerable: Math.random() is not cryptographically secure.

Timestamp as Randomness Source

// VULNERABLE - Low entropy
const token = Date.now().toString(36);  // ~11 chars, time-based
const random = new Date().getTime() + Math.random();

Why this is vulnerable: Timestamp predictable. Limited entropy.

Insufficient Random Bytes

const crypto = require('crypto');

// WRONG - Only 40 bits of entropy
const token = crypto.randomBytes(5).toString('hex');  // 10 hex chars

Why this is vulnerable: 40 bits = ~1 trillion possibilities. Brute-forceable.

Secure Patterns

crypto.randomBytes() for Session IDs (Node.js)

const crypto = require('crypto');

// SECURE - Cryptographically strong session ID
function generateSessionId() {
    // 32 bytes = 256 bits of entropy
    return crypto.randomBytes(32).toString('hex');
}

// Example: "3a7b9c8e4f1d2a5b6c7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9"
// Unpredictable even with knowledge of previous session IDs

Why this works:

  • crypto.randomBytes() uses OS CSPRNG: getrandom()//dev/urandom on Linux/Unix, CNG on Windows
  • OS-managed entropy: Application code never needs to seed or reseed it, and should not try
  • Unpredictable output: Unlike Math.random(), the output is intended for cryptographic use and cannot feasibly be predicted from previous outputs
  • 256 bits of entropy: 2^256 possibilities makes attacks computationally infeasible
  • Hex encoding for compatibility: 64-character string safe for cookies, URLs, database storage

crypto.randomBytes() for Reset Tokens

const crypto = require('crypto');

// SECURE - Password reset token
function generateResetToken() {
    // 32 bytes = 256 bits, base64-encoded
    return crypto.randomBytes(32).toString('base64url');
}

// Example: "A3b7K9xQmZpLr4tYwFj2nVc8hG1sE6uD..."
// URL-safe, unpredictable

Why this works:

  • Password reset tokens require high security: Grant temporary access to change account credentials
  • 256 bits of entropy: A 2^256 token space makes guessing infeasible in any reasonable timeframe
  • base64url encoding is URL-safe: Replaces + with -, / with _, omits padding for safe transmission
  • Independent random output: Each randomBytes() call unpredictable, unlike Math.random() where observation enables prediction
  • Tokens should be single-use and time-limited: Expire after 1 hour for defense-in-depth

crypto.randomInt() for Random Integers

const crypto = require('crypto');

// SECURE - Random integer in range
function generateVerificationCode() {
    // 6-digit code: 000000 to 999999
    const code = crypto.randomInt(0, 1000000);
    return code.toString().padStart(6, '0');
}

// Example: "047382"
// Uniformly distributed, cryptographically random

Why this works:

  • crypto.randomInt() uses rejection sampling: Avoids modulo bias for uniform distribution across ranges
  • Prevents statistical bias: Naive randomBytes() % range makes some numbers appear more frequently
  • Perfectly uniform distribution: Discards out-of-range values and retries until valid
  • Cryptographically secure: Suitable for 2FA, email verification, SMS confirmation codes
  • Unpredictable, but short: A six-digit code carries under 20 bits, which is enumerable on its own - crypto.randomInt() removes the prediction route, not the guessing one. It is safe only behind an attempt limit, an expiry and single-use enforcement, all checked server-side

crypto.randomUUID() for UUIDs

const crypto = require('crypto');

// SECURE - UUID v4 generation (Node.js 14.17+, 15.6+)
function generateUserId() {
    return crypto.randomUUID();
}
  • RFC 4122 version 4 UUIDs with 122 bits randomness: Version/variant bits fixed per RFC, rest cryptographically random
  • Sufficient uniqueness for distributed systems: No coordination needed, astronomically low collision probability
  • Superior to Math.random()-based UUIDs: Uses crypto.randomBytes() for entropy, not weak PRNGs
  • Universal database support: 8-4-4-4-12 format recognized by all major databases
  • Suitable for high-throughput systems: Millions of UUIDs/second with negligible collision risk
// Example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// Cryptographically secure UUID v4

crypto.getRandomValues() in Browser

// SECURE - Browser-side random values
function generateClientToken() {
    // Uint8Array of 32 random bytes
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);

    // Convert to hex string
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}

// Example: "3a7b9c8e4f1d2a5b6c7e8f9a0b1c2d3e..."
// Cryptographically secure in modern browsers

Why this works:

  • Browser equivalent of crypto.randomBytes(): Provides access to browser's CSPRNG
  • Platform-specific CSPRNGs: Uses OS-backed cryptographic random APIs such as getrandom()//dev/urandom on Linux/Unix, CNG on Windows, and SecRandomCopyBytes on macOS/iOS
  • Fills TypedArrays with random values: Can generate up to 65,536 bytes per call
  • Appropriate for client-side challenges and keys: CSP nonces, WebAuthn challenges, and end-to-end encryption keys can use browser CSPRNG output; never put OAuth client secrets in browser code
  • Broad browser support: Chrome, Firefox, Safari, and Edge; Web Crypto secure-context requirements vary by API

Encryption IV/Nonce Generation

const crypto = require('crypto');

// SECURE - Proper IV generation
function encrypt(data, key) {
    // 16 bytes IV for AES-256-CBC
    const iv = crypto.randomBytes(16);

    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    let encrypted = cipher.update(data, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    return {
        encrypted,
        iv: iv.toString('hex')
    };
}

// IV is unpredictable and unique

Why this works:

  • IVs/nonces prevent pattern analysis: Same plaintext produces different ciphertexts each encryption
  • 16-byte IV must be unpredictable for AES-CBC: Predictable IVs enable chosen-plaintext attacks
  • crypto.randomBytes() ensures independence: Each IV unpredictable from OS CSPRNG
  • IV doesn't need secrecy: Store it with the ciphertext; CBC requires unpredictability, while GCM requires nonce uniqueness under the same key
  • Collision probability negligible: 1 in 2^128 for 16-byte IVs
  • AES-GCM nonce reuse breaks security: 12-byte nonces are recommended; reuse under the same key can expose plaintext relationships and allow message forgery

CSRF Token Generation

const crypto = require('crypto');

// SECURE - Strong CSRF token
function generateCsrfToken(req, res) {
    const token = crypto.randomBytes(32).toString('base64');
    req.session.csrfToken = token;
    return token;
}

// Token like "A7b3C9d2E5f1G8h4..."
// Cryptographically unpredictable

Why this works:

  • CSRF protection relies on unpredictability: Attackers from third-party sites cannot guess token values
  • 256 bits of entropy: 1 billion guesses/second still takes longer than universe's age
  • base64 encoding: Compact string for hidden fields or HTTP headers
  • Server-side storage enables validation: Store in session or signed cookie; validate on state-changing requests
  • Cryptographic randomness prevents prediction: Observing thousands of tokens doesn't reveal generation algorithm
  • Superior to derived schemes: Unlike a token built by hashing the session ID, which is fully determined by that ID

API Key Generation

const crypto = require('crypto');

// SECURE - Cryptographically strong API key
function generateApiKey() {
    const prefix = 'sk_live_';
    const randomPart = crypto.randomBytes(24).toString('base64url');
    return prefix + randomPart;
}

// Example: "sk_live_A7b3K9xQmZpLr4tYwFj2nVc8..."
// 192 bits of entropy in random part

Why this works:

  • 192 bits of entropy: 1 billion guesses/second takes ~2^160 seconds (far beyond universe's age)
  • base64url encoding is URL-safe: Suitable for HTTP headers, query parameters, config files
  • Prefix identifies key type: sk_live_ enables pattern matching, rate limiting, revocation by type
  • One key compromise doesn't affect others: Cryptographic randomness prevents key prediction/derivation
  • Production best practices: Implement key rotation, scope limiting, audit logging

Key Security Functions

Secure Token Generator

const crypto = require('crypto');

class SecureTokenGenerator {
    /**
     * Generate secure tokens for various purposes
     * @param {string} purpose - 'session', 'reset', 'api', 'csrf'
     * @param {number} bytes - Number of random bytes (default 32)
     * @returns {string} Secure token
     */
    static generate(purpose, bytes = 32) {
        const token = crypto.randomBytes(bytes).toString('base64url');
        return `${purpose}_${token}`;
    }

    static session() {
        return this.generate('sess', 32);  // 256 bits
    }

    static resetPassword() {
        return this.generate('reset', 48);  // 384 bits (longer for password reset)
    }

    static apiKey() {
        return this.generate('sk_live', 32);
    }

    static csrf() {
        return this.generate('csrf', 24);  // 192 bits
    }
}

// Usage
const sessionToken = SecureTokenGenerator.session();
const resetToken = SecureTokenGenerator.resetPassword();

Entropy Estimator

function entropyBits(randomByteCount) {
    /**
     * Entropy of a token, in bits. Count the BYTES DRAWN, never the
     * characters printed - encoding rearranges entropy, it never adds any.
     * @param {number} randomByteCount - Bytes taken from the CSPRNG
     * @returns {number} Entropy in bits
     */
    return randomByteCount * 8;
}

// Usage
const bytes = 32;
const token = crypto.randomBytes(bytes).toString('base64');
console.log(token.length);            // 44 characters
console.log(entropyBits(bytes));      // 256 bits - the correct answer

// Minimum entropy recommendations:
// - Session tokens: 128 bits
// - Password reset: 128-256 bits
// - API keys: 128-256 bits
// - Encryption keys: 128-256 bits

Why counting characters gets this wrong: the obvious version of this helper takes the finished string and returns length * Math.log2(alphabetSize). For hex that happens to be right - 64 characters times 4 is 256 - and for base64 it is not. crypto.randomBytes(32).toString('base64') is 44 characters, and 44 x 6 = 264, eight bits more than the generator ever produced. The over-count splits in two: the final data character carries only four real bits rather than six, and the = is padding that carries none at all and is not even from the alphabet. An entropy check that reports more entropy than exists is worse than no check, because the case it will be run on is the borderline one - and the same arithmetic on a 16-byte token returns 144 bits against a true 128, so a value sitting exactly on the floor is reported as clearing it by a margin. Count the bytes you asked the CSPRNG for.

Secure Random String Generator

const crypto = require('crypto');

function generateSecureString(length, charset = 'alphanumeric') {
    /**
     * Generate cryptographically secure random string
     * @param {number} length - Desired string length
     * @param {string} charset - 'alphanumeric', 'hex', 'base64', 'numeric'
     * @returns {string} Random string
     */
    const charsets = {
        alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
        hex: '0123456789abcdef',
        base64: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',
        numeric: '0123456789'
    };

    const chars = charsets[charset] || charsets.alphanumeric;

    let result = '';
    for (let i = 0; i < length; i++) {
        // crypto.randomInt applies rejection sampling, so every character is
        // equally likely whatever the charset size. Indexing with
        // bytes[i] % chars.length would not: see the note below.
        result += chars[crypto.randomInt(chars.length)];
    }

    return result;
}

// Usage
const apiKey = generateSecureString(32, 'alphanumeric');
const pin = generateSecureString(6, 'numeric');
const hexToken = generateSecureString(64, 'hex');

Why randomInt rather than % chars.length: the modulo is only unbiased when the charset size divides 256, which is what makes this one easy to ship. hex has 16 characters and base64 has 64, so both divide evenly and the modulo version is correct for them. alphanumeric has 62: 256 leaves a remainder of 8, so the first 8 characters get 5 chances in 256 against 4 for the other 54 - 25% more likely. numeric has 10, giving digits 0-5 a 26/256 chance against 25/256 for 6-9.

So the same helper is correct for two of its four charsets and biased for the other two, which no test of the hex path would ever reveal. crypto.randomInt() rejects out-of-range values and resamples, so it is correct for every charset size.

Analysis Steps

Locate the weak random usage

// Line 23 in src/auth/tokens.js
function generatePasswordResetToken(userId) {
    const token = Math.random().toString(36).substring(2);  // VULNERABLE
    return `${userId}_${token}`;
}

Identify the purpose

  • Password reset token generation (critical security operation)
  • Used for account recovery
  • Predictable token = account takeover

Assess the risk

  • Token grants access to change password
  • Attacker can predict tokens for target user
  • Impact: Critical (full account takeover)

Calculate current entropy

// token = Math.random().toString(36).substring(2)
// toString(36) produces ~11 chars from Math.random()
// Alphabet: 36 chars (0-9, a-z)
// Entropy: 11 * log2(36) ≈ 57 bits
// INSUFFICIENT for security (need 128+ bits)

Considerations

Ask what guessing the value would get someone. Randomness has non-security uses everywhere - sampling, shuffling, jitter, cache-busting, test fixtures - and none of them need a CSPRNG. The finding is material when the value is a session identifier, a token, a key, an OTP, a salt, an IV, or anything else whose unpredictability is what makes it work. If it is not, the general-purpose generator is the correct choice and the finding should be closed with the reason recorded.

Do not blanket-replace. A cryptographic generator draws on the OS entropy pool and is meaningfully slower than a PRNG. That cost is irrelevant for a handful of tokens per request and very relevant in a simulation or a rendering loop generating millions of values. Replacing every call site to make a scanner quiet trades real throughput for no security benefit, and it makes the genuine findings harder to see.

crypto.pseudoRandomBytes() is a name, not a weaker generator. A finding on it reads exactly like this CWE and is not it, which is worth settling before anyone plans a token rotation. The name dates from when the export wrapped OpenSSL's RAND_pseudo_bytes; that function was removed in OpenSSL 1.1.0 and Node has aliased the export to randomBytes since. On Node 24.3, crypto.pseudoRandomBytes === crypto.randomBytes is true - the same function object, not merely equivalent behaviour - so the bytes already issued through it are cryptographically strong and do not need replacing. Rename the call, because it is deprecated and because the next reader will assume what you did, and close the finding as a false positive with that reason recorded.

Where the value is generated matters as much as how. A token minted in the browser is guessable regardless of the API used, because the code producing it is public and the attacker controls the runtime. crypto.getRandomValues() is right for client-side work like a local nonce; anything that grants access must be generated server-side.

Anything derived from a weak value stays weak. Hashing it, base64-encoding it, concatenating a timestamp, or truncating it changes how the output looks without adding entropy - the result is still fully determined by the predictable input. There is no post-processing that fixes the source; only replacing the generator does.

Check the length once the generator is right. This CWE is about the unpredictability of the value, which depends on both the source and how much of it you take. Four bytes from a cryptographic generator is still only 32 bits. Use at least 16 bytes for tokens and 32 for key material, and remember hex encoding doubles the character count, which is where half the intended entropy usually goes missing.

Common Pitfalls

  • Modulo bias after switching to a CSPRNG: Calling crypto.randomBytes() correctly, then deriving a "readable" token with bytes[i] % chars.length - if chars.length doesn't evenly divide 256, low values come up more often than high ones, biasing the output and undermining the entropy the CSPRNG provided. Use crypto.randomInt(), which applies rejection sampling, instead of manual modulo.
  • Partial migration leaving old helpers in place: Fixing server-side token generation but leaving a shared client-side utility (or an older helper function) still building UUID-shaped strings with Math.random() - the migration is often done call-site by call-site, and any code path still calling the old helper keeps producing predictable values.

Additional Resources