Security Reference

MCP server insecure randomness: Math.random() in tokens, session IDs, and OTP codes

Math.random() is a pseudorandom number generator seeded by a predictable state. It is fast and uniform — suitable for simulations, shuffles, and non-security uses. It is categorically unsuitable for generating any value an attacker must not be able to predict: session tokens, CSRF nonces, OTP codes, API keys, audit IDs, or password reset links.

Why Math.random() is predictable

V8's Math.random() implementation uses xorshift128+ with a 128-bit internal state. Given a sequence of outputs — which an attacker can observe if any random values are exposed externally — the full state can be reconstructed and future values predicted. Researchers have demonstrated this practically: observing 5 consecutive Math.random() outputs is sufficient to predict all future outputs from the same generator instance.

In MCP servers, random values leak in several ways: audit IDs in API responses, request IDs in error messages, CSRF tokens in client-visible cookies, temporary file names. If any of these are generated with Math.random(), an attacker who sees a few of them can predict the next password reset token.

// VULNERABLE patterns — all use Math.random() for security-sensitive values

// 1. Session token from Math.random()
function generateSessionToken() {
  return Math.random().toString(36).slice(2);  // ~52 bits, predictable
}

// 2. OTP code (6-digit)
function generateOTP() {
  return Math.floor(Math.random() * 1_000_000).toString().padStart(6, '0');
  // Only 1,000,000 possible values + predictable PRNG = trivially brute-forceable
}

// 3. "UUID" from Math.random() — a common Stack Overflow pattern
function generateFakeUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0;  // NOT cryptographically random
    return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
  });
}

// 4. API key from random hex
function generateApiKey() {
  return [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
}

Cryptographically secure replacements

import { randomBytes, randomUUID, randomInt } from 'crypto';

// SAFE 1: session token — 32 bytes of CSPRNG output, hex-encoded
function generateSessionToken() {
  return randomBytes(32).toString('hex');  // 256-bit entropy, unpredictable
}

// SAFE 2: OTP code — randomInt provides uniform CSPRNG distribution
function generateOTP() {
  // randomInt(min, max) uses rejection sampling for uniform distribution
  return randomInt(0, 1_000_000).toString().padStart(6, '0');
}

// SAFE 3: UUID — use the built-in CSPRNG-backed UUID v4
function generateId() {
  return randomUUID();  // Node.js 14.17+ — cryptographically secure
}

// SAFE 4: API key — 32 bytes = 256 bits of entropy
function generateApiKey() {
  return randomBytes(32).toString('base64url');  // URL-safe base64, 43 chars
}

// SAFE 5: CSRF token
function generateCsrfToken() {
  return randomBytes(32).toString('hex');  // 64-char hex string
}

// SAFE 6: Password reset token with expiry
function generatePasswordResetToken() {
  const token = randomBytes(32).toString('hex');
  const expires = Date.now() + 30 * 60 * 1000;  // 30 minutes
  return { token, expires };
}

The rejection-sampling problem with custom ranges

When you need a random integer in a range that doesn't evenly divide the PRNG output space, naïve modulo introduces bias. Node's randomInt handles this correctly; rolling your own does not:

// VULNERABLE: modulo bias — values at the start of the range are more likely
function biasedRandom(max) {
  return parseInt(randomBytes(4).toString('hex'), 16) % max;
  // If max = 6 and the 32-bit space is 4,294,967,296:
  // 4,294,967,296 / 6 = 715,827,882.67 — not evenly divisible
  // Values 0-3 appear once more than 4-5 in the first modulo wrap
}

// SAFE: use Node's built-in rejection-sampling randomInt
import { randomInt } from 'crypto';
const roll = randomInt(1, 7);  // uniform, rejection-sampling based

// SAFE alternative: use the Web Crypto API getRandomValues in any environment
function uniformRandom(max) {
  // Find the smallest bit mask >= max
  let mask = max - 1;
  mask |= mask >> 1;
  mask |= mask >> 2;
  mask |= mask >> 4;
  mask |= mask >> 8;
  mask |= mask >> 16;

  let val;
  do {
    val = parseInt(randomBytes(4).toString('hex'), 16) & mask;
  } while (val >= max);  // rejection sampling — loop executes < 2x on average
  return val;
}

MCP-specific exposure points

MCP servers have specific places where insecure randomness appears:

// 1. Audit ID in tool response — if generated with Math.random(),
//    an attacker who observes several audit IDs can predict future ones
//    and pre-register forged audit entries

// VULNERABLE
server.tool('runAudit', { repo: z.string() }, async ({ repo }) => {
  const auditId = 'au_' + Math.random().toString(36).slice(2);
  // ...
  return { auditId, results };
});

// SAFE
server.tool('runAudit', { repo: z.string() }, async ({ repo }) => {
  const auditId = 'au_' + randomBytes(12).toString('hex');  // 24-char, 96-bit
  return { auditId, results };
});

// 2. File upload temp names — predictable names can be used to guess
//    and pre-claim upload slots before the legitimate user

// VULNERABLE
const tmpPath = `/tmp/upload_${Math.random().toString(36).slice(2)}`;

// SAFE
const tmpPath = `/tmp/upload_${randomBytes(16).toString('hex')}`;

// 3. Rate limit bucket keys — if the bucket key includes a random nonce
//    that was generated with Math.random(), the nonce can be predicted
//    and the same bucket reused across rate-limit resets

// SAFE: use randomBytes for any value that must not be guessable
const bucketNonce = randomBytes(8).toString('hex');

uuid package warning: uuid package versions before v9 used Math.random() as a fallback when crypto.getRandomValues() was unavailable (e.g., in some older Node.js environments). Version 9+ always uses the crypto API. If you're using uuid for security-sensitive IDs, ensure you're on v9+ and that crypto is available in your environment.

Entropy exhaustion and seeding

// MYTH: "I seed Math.random() with Date.now() so it's unpredictable"
// V8 ignores manual seeding — Math.random() cannot be seeded by userland code
// Date.now() has millisecond precision and is guessable within a small window

// MYTH: "I combine multiple Math.random() calls so it's statistically safe"
const combined = Math.random().toString() + Math.random().toString();
// Still derived from the same predictable internal state

// MYTH: "I add a user ID so it's unique per user"
const token = userId + '_' + Math.random().toString(36);
// The userId is public or semi-public; Math.random() part is predictable

// REALITY: the only safe path is crypto.randomBytes() for all security values
// Node.js sources entropy from the OS (/dev/urandom on Linux, BCryptGenRandom
// on Windows). This is cryptographically strong and suitable for key material.

Audit ID format recommendations

For audit IDs and similar externally visible identifiers, use a format that makes the random component explicit and measurable:

// Recommended: timestamp prefix + random suffix
// - Sortable by creation time (useful for pagination)
// - Random component provides 128 bits of entropy
// - Prefix makes the namespace clear

function generateAuditId() {
  const ts = Date.now().toString(36);       // base36 timestamp, ~8 chars
  const rand = randomBytes(16).toString('hex'); // 32-char hex
  return `au_${ts}_${rand}`;
}
// Example: au_m1s2t3_3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c

// Simpler: just use randomUUID() — UUID v4 with crypto
import { randomUUID } from 'crypto';
const auditId = `au_${randomUUID().replace(/-/g, '')}`;
// Example: au_550e8400e29b41d4a716446655440000

SkillAudit grading criteria

FindingSeverityScore impact
Math.random() used for session tokens, API keys, or CSRF noncesHIGH−20
Math.random() used for OTP or password reset tokensHIGH−20
uuid package version < 9 (Math.random() fallback)HIGH−12
Math.random() used for audit IDs or externally visible identifiersMEDIUM−8
Custom modulo range without rejection samplingMEDIUM−6
crypto.randomBytes() used for all security-sensitive random valuesPASS+8
randomUUID() / randomInt() used appropriatelyPASS+5

Related SkillAudit checks