~/webline_global $

// Everyday tech, explained simply.

Why Your Node.js Crypto Seed Generator Produces Duplicate Nonces

· 7 min read
Why Your Node.js Crypto Seed Generator Produces Duplicate Nonces

You’re three hours into debugging a session and your Node.js crypto seed generator keeps spitting out duplicate nonces. Every run of crypto.randomBytes(32) looks unique in isolation, but your database logs tell a different story: collisions at the application layer, silent failures, and users hitting the same session token twice. You’re not alone, and the fix isn’t in the crypto module itself.

The problem isn’t randomness. Node’s crypto module wraps OpenSSL and uses the operating system’s entropy pool, which is cryptographically secure for nearly all production use cases. The real culprit is how you’re managing that randomness across concurrent operations, session resets, and edge cases in your seed logic.

The Nonce Lifecycle: Where Duplicates Actually Come From

A nonce—number used once—is supposed to be unique within a given scope. In crypto seed generation for iGaming platforms, payment integrations, or KYC token exchanges, a nonce might be a 256-bit random value, a timestamp-based sequence, or a hybrid of both. Duplicates happen when two processes generate the same value within the same time window, or when your seed generator reuses state it shouldn’t.

Race Conditions in Async Generators

Node.js is single-threaded, but it handles I/O asynchronously. If you’re calling crypto.randomBytes() inside a Promise.all() or a rapid for loop without awaiting properly, you can hit a subtle race condition. Consider this pattern:

const seeds = [];
for (let i = 0; i < 100; i++) {
  seeds.push(crypto.randomBytes(32).toString('hex'));
}

Looks fine, right? Each call to randomBytes is synchronous in this example, so they execute sequentially. But if you wrap them in Promise.all with an async wrapper, you might be interleaving calls that share state—like a global counter or a timestamp truncated to milliseconds.

The real danger is when your seed generator does something like:

function generateSeed() {
  const timestamp = Date.now();
  const random = crypto.randomBytes(16).toString('hex');
  return `${timestamp}-${random}`;
}

Date.now() returns milliseconds. If two calls happen within the same millisecond, the prefix is identical. The random part should still differ, but if your random generator has been seeded with the same entropy source (or you’re using a seeded PRNG for reproducibility), you can get collisions.

Seeded PRNGs and the Reproducibility Trap

Many indie developers fall into the trap of using a seeded pseudo-random number generator (PRNG) for nonce generation because they want deterministic sequences for testing or debugging. The crypto module’s randomBytes is not seeded by you—it pulls from the OS. But if you use crypto.createHash or crypto.createHmac to derive a nonce from a fixed seed, you’re building a time bomb.

How Seeded Generators Produce Collisions

Imagine you’re building a deck-shuffle algorithm for a live casino game. You seed a PRNG with a server-side master key and a round number. That’s fine for shuffling—you want determinism so you can audit the outcome later. But if you use the same PRNG to generate session nonces without mixing in additional entropy, you’ll eventually hit a repeat.

I once consulted for a small studio building a multiplayer card game. Their nonce generator used Math.seedrandom(roundNumber) (a common third-party library). Every round, the first nonce was identical. Players could predict the next session token if they knew the round number. The fix was trivial: mix crypto.randomBytes(4) into the seed before generating the nonce.

Entropy Exhaustion in Containerized Environments

This is the one that bites production deployments. When your Node.js app runs inside a Docker container, especially on a shared host or a lightweight VM, the entropy pool can run dry. crypto.randomBytes blocks until enough entropy is collected. If your app starts up and immediately generates 10,000 nonces, the OS might fall back to a less random source.

The /dev/urandom vs /dev/random Debate

On Linux, crypto.randomBytes uses /dev/urandom by default, which never blocks but can produce lower-quality randomness immediately after boot if the entropy pool is depleted. In practice, modern kernels mitigate this with a CSPRNG that reseeds from hardware sources, but in containerized environments, the available entropy can be artificially low.

I’ve seen a deployment where a Node.js microservice inside a Docker container on AWS ECS generated duplicate nonces every 200 requests. The root cause: the container’s entropy pool was shared across 50 other containers on the same host. The fix was to install haveged (a user-space entropy daemon) or switch to a hardware RNG source.

Practical check: Run cat /proc/sys/kernel/random/entropy_avail inside your container. If it’s consistently below 1000, you’re at risk of low-quality randomness.

The Database Uniqueness Assumption

Even if your nonces are truly random, your database might be introducing duplicates. This happens when your application logic assumes a nonce is unique without enforcing a unique constraint at the database level.

The Race Between Insert and Check

A common pattern in payment integration is:

async function createNonce() {
  const nonce = crypto.randomBytes(32).toString('hex');
  const existing = await db.query('SELECT id FROM nonces WHERE nonce = ?', [nonce]);
  if (existing.length === 0) {
    await db.query('INSERT INTO nonces (nonce) VALUES (?)', [nonce]);
    return nonce;
  }
  return createNonce(); // retry
}

This has two problems. First, the check-then-insert window is vulnerable to race conditions if two requests hit the same nonce simultaneously. Second, recursion without a depth limit can cause stack overflow under high load.

The correct approach is to use a database UNIQUE constraint and catch the duplicate error:

async function createNonce() {
  const nonce = crypto.randomBytes(32).toString('hex');
  try {
    await db.query('INSERT INTO nonces (nonce) VALUES (?)', [nonce]);
    return nonce;
  } catch (err) {
    if (err.code === 'ER_DUP_ENTRY') {
      return createNonce(); // retry with new nonce
    }
    throw err;
  }
}

This pattern is safe because the database enforces uniqueness atomically. You’re not relying on your application’s timing or state.

Timing Attacks and Nonce Reuse in WebSocket Sessions

If you’re building real-time features—like live game state sync or chat—WebSocket connections often use nonces to prevent replay attacks. A duplicate nonce here can let an attacker replay an old message and inject stale state into the session.

How Nonce Reuse Breaks WebSocket Security

In a typical iGaming WebSocket protocol, each message includes a nonce and a HMAC signature. The server tracks seen nonces to reject duplicates. If your nonce generator produces a repeat, the server will drop the second message, causing a silent failure. Worse, if the attacker can predict the next nonce, they can craft a valid message before the legitimate client sends it.

The fix is to use a counter-based nonce combined with a random per-session salt. For example:

const sessionSalt = crypto.randomBytes(8).toString('hex');
let counter = 0;

function nextNonce() {
  counter++;
  const raw = `${sessionSalt}-${counter}-${Date.now()}`;
  return crypto.createHash('sha256').update(raw).digest('hex');
}

This guarantees uniqueness within a session because the counter never repeats. The session salt ensures two different sessions can’t collide.

The UUIDv4 Fallacy

Many developers switch to UUIDv4 thinking it solves all nonce problems. UUIDv4 is 122 bits of random data, which is enormous—the chance of collision is astronomically low. But UUIDv4 generation in Node.js often uses Math.random() under the hood, not crypto.randomBytes.

When UUIDv4 Generators Use Weak Randomness

The popular uuid npm package (v9 and later) uses crypto.randomUUID() when available, which is secure. But older versions or certain polyfills fall back to Math.random(). If you’re using uuid.v4() in a legacy project, check the source. I’ve debugged a production issue where a team used short-uuid with a custom alphabet, and it was seeding from Date.now() + Math.random(). Collisions appeared after 10,000 IDs.

Rule of thumb: If you need a nonce for anything financial, authentication, or game-critical, generate it yourself with crypto.randomBytes(16) and hex-encode it. Don’t trust third-party UUID libraries without verifying their entropy source.

Monitoring for Nonce Collisions

You can’t fix what you don’t measure. Add instrumentation to your nonce generation pipeline to detect duplicates before they cause user-facing failures.

A Simple Collision Detector

const seenNonces = new Set();
let collisionCount = 0;

function generateAndCheck() {
  const nonce = crypto.randomBytes(16).toString('hex');
  if (seenNonces.has(nonce)) {
    collisionCount++;
    console.error(`Collision detected! Total: ${collisionCount}`);
  }
  seenNonces.add(nonce);
  return nonce;
}

This only works in a single process. For distributed systems, push nonces to a Redis set with an expiry TTL. If the SADD command returns 0, the nonce already exists.

Alert threshold: If you see more than 1 collision per million nonces, investigate immediately. Cryptographic PRNGs should produce zero collisions in practice for any reasonable volume.

What to Do Next

Stop blaming crypto.randomBytes. Audit your generator’s state management, your database constraints, and your deployment environment’s entropy availability. Add a unique constraint on your nonce column today—it’s a one-line migration that prevents silent data corruption.

Then, if you’re running in containers, check your entropy pool. If it’s low, install haveged or switch to a hardware RNG source. Your users won’t notice the difference until a duplicate nonce causes a payment to fail or a game round to replay. By then, it’s too late.