Skip to content

CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG) - JavaScript/Node.js

Overview

Use of Cryptographically Weak PRNG in JavaScript/Node.js applications occurs when developers use Math.random() for security-sensitive operations. Math.random() is not cryptographically secure and should never be used for generating tokens, keys, passwords, or other security-critical values. Its output stream is reproducible from a handful of observed values, so an attacker who has seen a few of the tokens an application issued can work out the rest.

Primary Defence: Use crypto.randomBytes() or crypto.randomUUID() (Node.js 14.17+) for all security-sensitive random value generation including session tokens, CSRF tokens, and API keys.

Common Vulnerable Patterns

Math.random() for Session Tokens

// VULNERABLE - Using Math.random() for session tokens
function generateWeakSessionToken() {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let token = '';

  for (let i = 0; i < 32; i++) {
    const randomIndex = Math.floor(Math.random() * chars.length);
    token += chars[randomIndex];
  }

  return token;
}

// Express route with vulnerable session generation
const express = require('express');
const session = require('express-session');

const app = express();

app.use(session({
  genid: () => generateWeakSessionToken(),  // VULNERABLE!
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: true
}));

app.post('/login', (req, res) => {
  const { username, password } = req.body;

  if (authenticateUser(username, password)) {
    // VULNERABLE - Predictable session ID
    req.session.userId = getUserId(username);
    req.session.token = generateWeakSessionToken();

    res.json({
      status: 'logged_in',
      sessionToken: req.session.token
    });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

function authenticateUser(username, password) {
  // Auth logic
  return true;
}

function getUserId(username) {
  return 123;
}

Why this is vulnerable:

  • V8 implements Math.random() with xorshift128+, a statistical generator with 128 bits of state that V8's own documentation describes as "still not cryptographically secure". Nothing about it is designed to withstand an observer: given enough consecutive outputs the state solves out, and from there the whole stream - past and future - follows. There is no seed to protect and no amount of extra calls that helps.
  • The 32-character token looks like 190 bits and is bounded by that 128-bit state, and in practice by far less, because whoever recovers the state does not have to guess anything at all.
  • The runtime detail worth knowing before you dismiss a finding as theoretical: V8 does not compute values one at a time. It generates a batch of 64 doubles into a cache and serves calls out of it, so a prediction attempt that models Math.random() as one generator step per call is modelling the wrong thing. Failing to reproduce the sequence on the first try is not evidence the tokens are safe.
  • genid here makes the weakness the Express session identifier itself, so predicting one is session hijacking directly - there is no separate token to steal first.

Weak API Key Generation

// VULNERABLE - Math.random() for API keys
function generateWeakAPIKey() {
  const prefix = 'sk_';
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let key = prefix;

  for (let i = 0; i < 32; i++) {
    key += chars[Math.floor(Math.random() * chars.length)];
  }

  return key;
}

// Fastify route with weak API key generation
const fastify = require('fastify')({ logger: true });

fastify.post('/api/keys', async (request, reply) => {
  const { userId, description } = request.body;

  // VULNERABLE - Predictable API key
  const apiKey = generateWeakAPIKey();

  // Store in database
  await db.apiKeys.insert({
    userId,
    apiKey,
    description,
    createdAt: new Date()
  });

  return {
    apiKey,
    message: 'Store this key securely'
  };
});

Why this is vulnerable:

  • The key body is 32 characters drawn from Math.random(), so its unpredictability is bounded by the generator's state and not by its length.
  • An attacker who holds one key - their own, issued legitimately - has a sample of that stream. Recovering the state from it yields the keys the same process issued to other accounts.

Weak Password Reset Tokens

// VULNERABLE - Weak token generation
function generateWeakResetToken() {
  // Using timestamp and Math.random() - both predictable!
  const timestamp = Date.now().toString();
  const randomPart = Math.random().toString(36).substring(2);

  return timestamp + randomPart;
}

// Next.js API route with weak reset tokens
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const { email } = req.body;

    // VULNERABLE - Predictable reset token
    const resetToken = generateWeakResetToken();

    // Store in database
    await db.passwordResets.create({
      email,
      token: resetToken,
      expiresAt: new Date(Date.now() + 3600000)  // 1 hour
    });

    // Send email
    await sendPasswordResetEmail(email, resetToken);

    res.status(200).json({ message: 'Reset email sent' });
  }
}

async function sendPasswordResetEmail(email, token) {
  // Email sending logic
}

Why this is vulnerable:

  • Date.now() is not a secret. An attacker who triggered the reset knows roughly when the token was minted, so the timestamp half narrows to a small range rather than adding entropy.
  • What is left is one Math.random() call rendered in base 36, and that rests on the same recoverable generator state.
  • A predicted reset token is an account takeover on its own: the holder sets a new password without ever reaching the mailbox the link was sent to.

Weak UUID Generation

// VULNERABLE - Custom UUID with Math.random()
function generateWeakUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

// Express route with weak UUIDs
const express = require('express');
const app = express();

app.post('/api/users', async (req, res) => {
  const { username, email } = req.body;

  // VULNERABLE - Predictable user IDs
  const userId = generateWeakUUID();

  await db.users.create({
    id: userId,
    username,
    email
  });

  res.json({
    userId,
    username
  });
});

Why this is vulnerable:

  • Every hex digit comes from Math.random(), so the value has the shape of a v4 UUID without the unpredictability one implies.
  • Anyone who recovers the generator state can compute the IDs issued before and after their own, which turns any endpoint that takes a user ID into an enumeration of the account table.

Weak CSRF Token

// VULNERABLE - Math.random() for CSRF tokens
function generateWeakCSRFToken() {
  return Math.random().toString(36).substring(2) +
         Math.random().toString(36).substring(2);
}

// Express middleware with weak CSRF
const express = require('express');
const app = express();

app.use((req, res, next) => {
  if (!req.session.csrfToken) {
    // VULNERABLE - Weak CSRF token
    req.session.csrfToken = generateWeakCSRFToken();
  }
  next();
});

app.get('/form', (req, res) => {
  res.render('form', {
    csrfToken: req.session.csrfToken
  });
});

app.post('/submit', (req, res) => {
  const userToken = req.body.csrfToken;
  const sessionToken = req.session.csrfToken;

  if (userToken !== sessionToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  // Process form
  res.json({ status: 'success' });
});

Why this is vulnerable:

  • The token is two Math.random() outputs in base 36, so an attacker who has solved for the generator state can compute the value sitting in another user's session.
  • A CSRF token the attacker can compute is not a control at all: the forged cross-site request carries the right value and the equality check at /submit passes.

Weak IV Generation

const crypto = require('crypto');

// VULNERABLE - Weak IV using Math.random()
function weakEncrypt(plaintext, key) {
  // WRONG: Using Math.random() for IV
  const iv = Buffer.from(
    Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
  );

  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
  encrypted += cipher.final('base64');

  return {
    iv: iv.toString('base64'),
    encrypted
  };
}

// Express route using weak encryption
const express = require('express');
const app = express();

const ENCRYPTION_KEY = crypto.randomBytes(32);

app.post('/api/encrypt', express.json(), (req, res) => {
  const { data } = req.body;

  // VULNERABLE - Weak IV compromises encryption
  const encrypted = weakEncrypt(data, ENCRYPTION_KEY);

  res.json({ encrypted });
});

Why this is vulnerable:

  • CBC mode needs an IV the attacker cannot predict, not merely one that varies. Here the 16 bytes come from Math.random(), so an attacker tracking the generator knows the IV of the next encryption before the plaintext is submitted.
  • That is the precondition for a chosen-plaintext attack: with the next IV known, the attacker can submit a block chosen so that its ciphertext confirms or rules out a guess at earlier plaintext, and recover the earlier message a guess at a time.

Weak OTP Generation

// VULNERABLE - Math.random() for OTP
function generateWeakOTP() {
  return Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
}

// NestJS controller with weak OTP
import { Controller, Post, Body } from '@nestjs/common';

@Controller('api/auth')
export class AuthController {
  @Post('send-otp')
  async sendOTP(@Body() body: { phoneNumber: string }) {
    // VULNERABLE - Predictable OTP
    const otp = generateWeakOTP();

    // Store OTP
    await this.otpService.saveOTP(body.phoneNumber, otp);

    // Send SMS
    await this.smsService.send(body.phoneNumber, `Your OTP is: ${otp}`);

    return { message: 'OTP sent' };
  }

  @Post('verify-otp')
  async verifyOTP(@Body() body: { phoneNumber: string, otp: string }) {
    const valid = await this.otpService.verify(body.phoneNumber, body.otp);

    if (!valid) {
      return { error: 'Invalid OTP' };
    }

    return { status: 'verified' };
  }
}

Why this is vulnerable:

  • A six-digit code has only a million values, so it relies on being both rate-limited and unpredictable. Math.random() removes the second of those.
  • An attacker who has recovered the generator state gets the code being sent to someone else's phone, and with it the second factor the login depends on.

Weak Password Generation

// VULNERABLE - Math.random() for password generation
function generateWeakPassword(length = 12) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
  let password = '';

  for (let i = 0; i < length; i++) {
    password += chars[Math.floor(Math.random() * chars.length)];
  }

  return password;
}

// Express route with weak password generation
const express = require('express');
const app = express();

app.post('/api/users/reset-password', async (req, res) => {
  const { email } = req.body;

  // VULNERABLE - Weak temporary password
  const tempPassword = generateWeakPassword(12);

  // Update user password
  await db.users.update(
    { email },
    { password: hashPassword(tempPassword) }
  );

  // Send email
  await sendEmail(email, `Your temporary password is: ${tempPassword}`);

  res.json({ message: 'Temporary password sent' });
});

function hashPassword(password) {
  // Password hashing logic
  return password;
}

async function sendEmail(email, message) {
  // Email sending logic
}

Why this is vulnerable:

  • The temporary password is 12 characters straight from Math.random(), so it inherits the generator's state rather than the strength its alphabet suggests.
  • It is a live credential the moment the route sets it. An attacker who predicts it logs in as that user before the real owner has read the email.

Secure Patterns

Using crypto.randomBytes() for Tokens

const crypto = require('crypto');

function generateSecureSessionToken() {
  // SECURE - Using crypto.randomBytes()
  return crypto.randomBytes(32).toString('base64url');
}

function generateHexToken() {
  // Alternative: Hex encoding
  return crypto.randomBytes(32).toString('hex');
}

// Express with secure session tokens
const express = require('express');
const session = require('express-session');

const app = express();

// SECURE - Random secret key
const SESSION_SECRET = crypto.randomBytes(64).toString('hex');

app.use(session({
  genid: () => generateSecureSessionToken(),
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,  // HTTPS only
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000  // 24 hours
  }
}));

app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  if (await authenticateUser(username, password)) {
    // SECURE - Cryptographically strong session token
    req.session.userId = await getUserId(username);
    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).json({ error: 'Session error' });
      }

      res.json({
        status: 'logged_in',
        sessionId: req.sessionID
      });
    });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

async function authenticateUser(username, password) {
  // Secure authentication logic
  return true;
}

async function getUserId(username) {
  return 123;
}

module.exports = app;

Why this works:

  • Cryptographically secure source: Node.js's crypto.randomBytes() uses OS-backed CSPRNGs through OpenSSL and platform random APIs, providing unpredictable entropy
  • 32-byte tokens: 256 bits of entropy, 2^256 possible values - guessing one is not an attack that can be mounted
  • URL-safe encoding: base64url encoding replaces + with -, / with _, removes padding for safe transmission in URLs/headers/cookies
  • Compact format: More efficient than hex (43 vs 64 characters for 32 bytes) while preserving full entropy
  • Security-critical use cases: Appropriate for session tokens, API keys, CSRF tokens, and any identifier requiring cryptographic unpredictability

Secure API Key Generation

const crypto = require('crypto');

class SecureAPIKeyService {
  static generateAPIKey() {
    // SECURE - Cryptographically strong API key
    const randomBytes = crypto.randomBytes(32);
    return 'sk_live_' + randomBytes.toString('base64url');
  }

  static hashAPIKey(apiKey) {
    // Hash for storage (never store plaintext)
    return crypto.createHash('sha256')
      .update(apiKey)
      .digest('hex');
  }

  static async createAPIKey(userId, description) {
    const apiKey = this.generateAPIKey();
    const keyHash = this.hashAPIKey(apiKey);

    // Store hash in database
    await db.apiKeys.insert({
      userId,
      keyHash,  // Store hash, not plaintext
      description,
      createdAt: new Date(),
      lastUsed: null
    });

    // Return plaintext key only once
    return apiKey;
  }

  static async verifyAPIKey(apiKey) {
    const keyHash = this.hashAPIKey(apiKey);

    const key = await db.apiKeys.findOne({
      keyHash,
      revoked: false
    });

    if (!key) {
      return null;
    }

    // Update last used
    await db.apiKeys.update(
      { _id: key._id },
      { $set: { lastUsed: new Date() } }
    );

    return key;
  }
}

// Fastify route with secure API keys
const fastify = require('fastify')({ logger: true });

fastify.post('/api/keys', async (request, reply) => {
  const { userId, description } = request.body;

  // SECURE - Cryptographically strong API key
  const apiKey = await SecureAPIKeyService.createAPIKey(userId, description);

  return {
    apiKey,
    warning: 'Store this key securely. It will not be shown again.'
  };
});

fastify.get('/api/protected', {
  preHandler: async (request, reply) => {
    const authHeader = request.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      reply.code(401).send({ error: 'Missing API key' });
      return;
    }

    const apiKey = authHeader.substring(7);
    const key = await SecureAPIKeyService.verifyAPIKey(apiKey);

    if (!key) {
      reply.code(401).send({ error: 'Invalid API key' });
      return;
    }

    request.userId = key.userId;
  }
}, async (request, reply) => {
  return {
    message: 'Access granted',
    userId: request.userId
  };
});

module.exports = fastify;

Why this works:

  • 256-bit cryptographic entropy: crypto.randomBytes(32) makes keys globally unique and impossible to predict
  • Hash before storage: a dump of the apiKeys table yields SHA-256 digests, not keys that will authenticate
  • One-time display: the plaintext exists in the creation response and nowhere else, and the stored hash cannot give it back
  • Industry-standard prefix: sk_live_ naming (Stripe convention) enables automated scanning for accidental commits and distinguishes key types (sk_test_ vs sk_live_)

Secure Password Reset Tokens

const crypto = require('crypto');

class SecurePasswordResetService {
  static generateResetToken() {
    // SECURE - 32 bytes of cryptographically secure random data
    return crypto.randomBytes(32).toString('base64url');
  }

  static async initiateReset(email) {
    const resetToken = this.generateResetToken();

    // Store token with expiration
    await db.passwordResets.insert({
      email,
      token: resetToken,
      createdAt: new Date(),
      expiresAt: new Date(Date.now() + 3600000),  // 1 hour
      used: false
    });

    // Send email
    await this.sendResetEmail(email, resetToken);

    return { message: 'Reset email sent' };
  }

  static async resetPassword(token, newPassword) {
    const reset = await db.passwordResets.findOne({
      token,
      used: false,
      expiresAt: { $gt: new Date() }
    });

    if (!reset) {
      throw new Error('Invalid or expired token');
    }

    // Update password
    await db.users.update(
      { email: reset.email },
      { password: await hashPassword(newPassword) }
    );

    // Mark token as used
    await db.passwordResets.update(
      { _id: reset._id },
      { $set: { used: true } }
    );

    return { message: 'Password reset successful' };
  }

  static async sendResetEmail(email, token) {
    const resetUrl = `https://example.com/reset-password?token=${token}`;

    // Send email (using your email service)
    await emailService.send({
      to: email,
      subject: 'Password Reset Request',
      text: `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour.`
    });
  }
}

// Next.js API route with secure reset tokens
export default async function handler(req, res) {
  if (req.method === 'POST' && req.url === '/api/reset-password') {
    const { email } = req.body;

    try {
      // SECURE - Cryptographically strong reset token
      await SecurePasswordResetService.initiateReset(email);
      res.status(200).json({ message: 'Reset email sent' });
    } catch (error) {
      res.status(500).json({ error: 'Reset failed' });
    }
  } else if (req.method === 'POST' && req.url === '/api/reset-password/confirm') {
    const { token, newPassword } = req.body;

    try {
      await SecurePasswordResetService.resetPassword(token, newPassword);
      res.status(200).json({ message: 'Password reset successful' });
    } catch (error) {
      // The only message this path throws deliberately is the one below. A bcrypt
      // or database failure lands in the same catch, and its text is not the
      // caller's business.
      console.warn('password reset confirm failed', { reason: error.message });
      res.status(400).json({ error: 'Invalid or expired token' });
    }
  }
}

async function hashPassword(password) {
  const bcrypt = require('bcrypt');
  return await bcrypt.hash(password, 12);
}

Why this works:

  • Cryptographic unpredictability: 256-bit entropy from crypto.randomBytes(32) prevents guessing even with email/timing knowledge
  • One-time use: Marking tokens used: true prevents replay attacks from intercepted reset emails
  • Time-limited window: Short expiration (typically 1 hour) limits exploitation even if token is compromised
  • Automatic expiration: Database query with timestamp check rejects stale tokens without manual cleanup
  • Proof of ownership: More secure than temporary passwords - requires email access to set new password
  • A fixed rejection message: the confirm handler answers Invalid or expired token whatever went wrong, so the endpoint cannot be used to tell an unused token from an expired one, or either from a database failure

Using crypto.randomUUID()

const crypto = require('crypto');

// SECURE - crypto.randomUUID() (Node.js 14.17+)
function generateSecureUUID() {
  return crypto.randomUUID();
}

// Express route with secure UUIDs
const express = require('express');
const app = express();
app.use(express.json());

app.post('/api/users', async (req, res) => {
  const { username, email } = req.body;

  // SECURE - Cryptographically strong UUID
  const userId = crypto.randomUUID();

  await db.users.create({
    id: userId,
    username,
    email,
    createdAt: new Date()
  });

  res.json({
    userId,
    username
  });
});

// Alternative: Secure custom ID generation
function generateSecureId(prefix = 'id_') {
  const randomPart = crypto.randomBytes(16).toString('base64url');
  return prefix + randomPart;
}

module.exports = app;

Why this works:

  • 122 bits of randomness: RFC 4122 v4 UUIDs use crypto.randomBytes() internally (6 bits for version/variant), sufficient to prevent collisions even with trillions generated
  • Prevents enumeration attacks: Unpredictability stops attackers from discovering resources via sequential ID incrementing
  • Ideal for public identifiers: REST API resources, URLs, job queue IDs, file names - anywhere a sequential pattern would leak information
  • Wide interoperability: Standardized 8-4-4-4-12 hex format recognized across databases, APIs, and languages
  • Security consideration: For session tokens or API secrets, prefer explicit 256-bit tokens via crypto.randomBytes()

Secure CSRF Token

const crypto = require('crypto');

function generateSecureCSRFToken() {
  // SECURE - Cryptographically strong CSRF token
  return crypto.randomBytes(32).toString('base64url');
}

// Express with secure CSRF protection
const express = require('express');
const csrf = require('@dr.pogodin/csurf');   // the original `csurf` is archived; this fork keeps the API

const app = express();

// Middleware generates tokens with crypto.randomBytes internally
const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  }
});

app.use(csrfProtection);

app.get('/form', (req, res) => {
  // SECURE - CSRF token from the middleware, not from Math.random()
  res.render('form', {
    csrfToken: req.csrfToken()
  });
});

app.post('/submit', (req, res) => {
  // CSRF validation happens automatically
  // Process form
  res.json({ status: 'success' });
});

// Manual CSRF implementation (if not using middleware)
class SecureCSRFService {
  static generateToken() {
    return crypto.randomBytes(32).toString('base64url');
  }

  static verifyToken(userToken, storedToken) {
    if (typeof userToken !== 'string' || typeof storedToken !== 'string') {
      return false;
    }

    // crypto.timingSafeEqual THROWS RangeError on unequal lengths, so hash
    // both to a fixed 32 bytes first. Comparing the digests is still
    // constant-time, and a wrong-length token now returns false instead of
    // crashing the handler.
    const a = crypto.createHash('sha256').update(userToken).digest();
    const b = crypto.createHash('sha256').update(storedToken).digest();

    return crypto.timingSafeEqual(a, b);
  }
}

module.exports = app;

Why this works:

  • Unpredictable tokens: the middleware uses crypto.randomBytes() for 256-bit tokens preventing guessing/forgery
  • Timing-safe comparison: crypto.timingSafeEqual() compares in constant time, so verification does not leak how many leading characters matched. It has one sharp edge worth knowing before you call it: it throws RangeError: Input buffers must have the same byte length rather than returning false when the lengths differ, which is exactly what an attacker sending a truncated token produces. Verified on Node 24. Hashing both sides to a fixed width first, as above, keeps the comparison constant-time and turns a crash into a rejection
  • Session binding: Tokens stored in session and required in POST/PUT/DELETE bind to authenticated user, readable only by same-origin
  • Double-submit pattern: Token in both cookie and request body avoids server-side session storage - but only in its signed form, where the token is HMAC'd with a server-side secret and bound to the session. A raw comparison of the two values is bypassable by anyone who can set a cookie on the domain; see CWE-352
  • Automatic handling: a maintained Express CSRF middleware manages generation, validation and rotation, reducing implementation errors. The original csurf package is archived - see CWE-352 for the maintained options
  • Essential protection: Required for any application with authenticated state-changing operations

Secure IV and Salt Generation

const crypto = require('crypto');

class SecureEncryption {
  static NONCE_LENGTH = 12;
  static TAG_LENGTH = 16;

  constructor(masterKey) {
    this.masterKey = masterKey;
  }

  static generateKey() {
    // SECURE - Generate encryption key
    return crypto.randomBytes(32);  // 256 bits for AES-256
  }

  static generateSalt() {
    // SECURE - Generate salt for key derivation
    return crypto.randomBytes(32);  // 256 bits
  }

  encrypt(plaintext) {
    // SECURE - Generate cryptographically strong nonce
    const nonce = crypto.randomBytes(SecureEncryption.NONCE_LENGTH);  // 96 bits for AES-GCM

    const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, nonce);

    let encrypted = cipher.update(plaintext, 'utf8');
    encrypted = Buffer.concat([encrypted, cipher.final()]);

    const authTag = cipher.getAuthTag();

    // Combine nonce + auth tag + ciphertext
    const combined = Buffer.concat([nonce, authTag, encrypted]);

    return combined.toString('base64');
  }

  decrypt(encryptedData) {
    const combined = Buffer.from(encryptedData, 'base64');

    // Extract components
    const nonce = combined.slice(0, SecureEncryption.NONCE_LENGTH);
    const authTag = combined.slice(
      SecureEncryption.NONCE_LENGTH,
      SecureEncryption.NONCE_LENGTH + SecureEncryption.TAG_LENGTH
    );
    const encrypted = combined.slice(SecureEncryption.NONCE_LENGTH + SecureEncryption.TAG_LENGTH);

    const decipher = crypto.createDecipheriv('aes-256-gcm', this.masterKey, nonce);
    decipher.setAuthTag(authTag);

    let decrypted = decipher.update(encrypted);
    decrypted = Buffer.concat([decrypted, decipher.final()]);

    return decrypted.toString('utf8');
  }
}

// Express route using secure encryption
const express = require('express');
const app = express();
app.use(express.json());

// Master key injected at start-up from a secret store - not stored in the environment (CWE-526)
const MASTER_KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'base64');
const encryptor = new SecureEncryption(MASTER_KEY);

app.post('/api/encrypt', (req, res) => {
  const { data } = req.body;

  // SECURE - Uses crypto.randomBytes() for the GCM nonce
  const encrypted = encryptor.encrypt(data);

  res.json({ encrypted });
});

app.post('/api/decrypt', (req, res) => {
  const { encrypted } = req.body;

  try {
    const decrypted = encryptor.decrypt(encrypted);
    res.json({ decrypted });
  } catch (error) {
    res.status(400).json({ error: 'Decryption failed' });
  }
});

module.exports = app;

Why this works:

  • Unique random nonces: crypto.randomBytes(12) generates cryptographically random 96-bit GCM nonces; reusing a nonce with the same key catastrophically breaks confidentiality and authentication
  • Authenticated encryption: AES-GCM encrypts and generates 16-byte authentication tag detecting tampering, preventing ciphertext manipulation
  • Nonce uniqueness requirement: The GCM nonce must be unique per encryption with the same key (not secret) and is typically stored/transmitted with ciphertext
  • Integrity guarantee: Authentication tag ensures even single-bit modifications cause decryption failure, not corrupted plaintext
  • Critical warning: Never hardcode, reuse, or use timestamp-derived nonces

Secure OTP Generation

const crypto = require('crypto');

class SecureOTPService {
  static generateNumericOTP(length = 6) {
    // SECURE - Cryptographically strong numeric OTP
    let otp = '';

    for (let i = 0; i < length; i++) {
      // Use crypto.randomInt() for secure random integers
      otp += crypto.randomInt(0, 10);
    }

    return otp;
  }

  static generateAlphanumericOTP(length = 8) {
    // SECURE - Alphanumeric OTP
    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';  // Exclude ambiguous chars
    let otp = '';

    for (let i = 0; i < length; i++) {
      const randomIndex = crypto.randomInt(0, chars.length);
      otp += chars[randomIndex];
    }

    return otp;
  }

  static async createOTP(identifier, type = 'numeric') {
    const code = type === 'numeric' ?
      this.generateNumericOTP(6) :
      this.generateAlphanumericOTP(8);

    await db.otps.insert({
      identifier,
      code,
      type,
      createdAt: new Date(),
      expiresAt: new Date(Date.now() + 600000),  // 10 minutes
      used: false
    });

    return code;
  }

  static async verifyOTP(identifier, code) {
    const otp = await db.otps.findOne({
      identifier,
      code,
      used: false,
      expiresAt: { $gt: new Date() }
    });

    if (!otp) {
      return false;
    }

    // Mark as used
    await db.otps.update(
      { _id: otp._id },
      { $set: { used: true } }
    );

    return true;
  }
}

// NestJS controller with secure OTP
import { Controller, Post, Body } from '@nestjs/common';

@Controller('api/auth')
export class AuthController {
  @Post('send-otp')
  async sendOTP(@Body() body: { phoneNumber: string }) {
    // SECURE - Cryptographically strong OTP
    const otp = await SecureOTPService.createOTP(body.phoneNumber);

    // Send SMS
    await smsService.send(body.phoneNumber, `Your OTP is: ${otp}`);

    return { message: 'OTP sent' };
  }

  @Post('verify-otp')
  async verifyOTP(@Body() body: { phoneNumber: string, otp: string }) {
    const valid = await SecureOTPService.verifyOTP(body.phoneNumber, body.otp);

    if (!valid) {
      return { error: 'Invalid or expired OTP' };
    }

    return { status: 'verified' };
  }
}

Why this works:

  • Uniform cryptographic randomness: crypto.randomInt() rejection-samples rather than taking a modulo, so each digit is equally likely; drawing six of them gives the full million-value range with no digit biased (500K average brute-force attempts). A single randomInt(0, 1000000) would be equivalent - what would not is randomBytes(4) % 1000000, which is where the bias creeps back in
  • Layered defenses: One-time use + short expiration (5-10 min) + rate limiting (3-5 attempts) makes brute-force practically impossible
  • Prevents prediction: Cryptographic strength ensures future OTPs unpredictable from past samples, unlike weak PRNGs
  • Automatic cleanup: Timestamp and used status enable expiration management and prevent replay attacks
  • Security scaling: 8-digit OTPs give 100M combinations; the alphanumeric form above draws 8 characters from a deliberately unambiguous 32-character alphabet (no I, O, 0 or 1), which is 32^8, about 1.1x10^12. Dropping the confusable characters costs entropy per character, so size the code against the alphabet you shipped rather than against a full 36
  • Balanced approach: Suitable for email/SMS 2FA, temporary codes where convenience balances security

Common Pitfalls

  • Falling back to Math.random() when crypto.randomBytes() errors: Some code catches the error from the callback-based crypto.randomBytes(size, callback) form and falls back to Math.random() as "graceful degradation." That fallback only fires in low-entropy or error conditions - exactly when unpredictability matters most and least likely to be caught in testing.
  • Trusting a token the client claims to have generated: Generating the CSRF/session token or a password-reset code with window.crypto.getRandomValues() in client-side JavaScript and sending it to the server to store. The browser environment is attacker-controlled, so any value the client says it generated must be treated as unverified input, not as the server-side secret.
  • Validating a securely-generated token with === instead of crypto.timingSafeEqual(): The RNG itself is secure, but a non-constant-time string comparison during validation leaks timing information about how many leading characters matched, letting an attacker incrementally guess the token even though it was generated securely.

Additional Resources