Modulo bias in cryptographic random values

Modulo bias in cryptographic random values

Description

Reducing a cryptographically secure random value to a smaller range with % (modulo) can create an uneven distribution when the original range is not divisible by the target range. This is called modulo bias: some values occur more often than others. An attacker may exploit that imbalance to improve guesses of tokens, codes, or identifiers, or take advantage of increased collisions.

Potential impact

  • More frequent values in password-reset tokens, session IDs, or OTPs may improve an attacker's guessing probability.
  • Possible values are not necessarily removed, but trying the more frequent values first can increase the chance of success.
  • Guessable CSRF tokens, invitation codes, or registration codes may enable account compromise or misuse of permissions.
  • Repeated authentication or coupon codes may increase the risk of reuse, fraud, or service abuse.

Remediation

  • Do not directly reduce cryptographic random values with % or similar operations.
  • In Node.js, use an API that avoids bias, such as crypto.randomInt().
  • If range reduction must be implemented directly, use rejection sampling to discard values outside an evenly divisible range.
  • For string tokens, use a reviewed library such as crypto-random-string, or encode enough random bytes directly with a suitable encoding such as hex or base64url.

Examples

Before

javascript
// Unsafe: generate a six-digit code with modulo bias
const crypto = require("crypto");

function generateOtpBad() {
  // Using % to reduce the range to 0..999999 introduces bias
  const raw = crypto.randomBytes(4).readUInt32BE();
  const n = raw % 1_000_000; // Unsafe: not uniform
  return n.toString().padStart(6, "0");
}

console.log(generateOtpBad());

After

javascript
// Use an unbiased API or rejection sampling
const crypto = require("crypto");

// 1) Recommended: the built-in unbiased Node.js API
function generateOtpSafe() {
  const n = crypto.randomInt(0, 1_000_000); // Exclusive upper bound, uniform distribution
  return n.toString().padStart(6, "0");
}

// 2) Alternative: rejection sampling
function generateOtpSafeLegacy() {
  const bound = 1_000_000;
  const max = Math.floor(0x1_0000_0000 / bound) * bound; // Largest multiple of bound no greater than 2^32
  while (true) {
    const r = crypto.randomBytes(4).readUInt32BE();
    if (r < max) {
      return (r % bound).toString().padStart(6, "0");
    }
  }
}

console.log(generateOtpSafe());

Explanation:

  • Before: Taking values from 0..2^32-1 modulo 1,000,000 makes some results more frequent because 2^32 is not divisible by 1,000,000. This gives an attacker a higher probability of guessing certain codes or tokens.
  • After: crypto.randomInt() avoids bias and produces a uniform distribution within the specified range. Rejection sampling discards values at or above the evenly divisible upper bound (max) before applying modulo, giving each result the same probability.

References