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-1modulo 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.