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
| Finding | Severity | Score impact |
|---|---|---|
| Math.random() used for session tokens, API keys, or CSRF nonces | HIGH | −20 |
| Math.random() used for OTP or password reset tokens | HIGH | −20 |
| uuid package version < 9 (Math.random() fallback) | HIGH | −12 |
| Math.random() used for audit IDs or externally visible identifiers | MEDIUM | −8 |
| Custom modulo range without rejection sampling | MEDIUM | −6 |
| crypto.randomBytes() used for all security-sensitive random values | PASS | +8 |
| randomUUID() / randomInt() used appropriately | PASS | +5 |
Related SkillAudit checks
- Cryptography security — insecure randomness is one of several crypto anti-patterns
- Credential exposure — predictable API keys are effectively exposed credentials
- Session fixation security — predictable session tokens amplify fixation attacks
- Input validation patterns — output validation should check that generated tokens meet entropy requirements