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. genidhere 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
/submitpasses.
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:
base64urlencoding 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
apiKeystable 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_vssk_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: trueprevents 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 tokenwhatever 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 throwsRangeError: Input buffers must have the same byte lengthrather than returningfalsewhen 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
csurfpackage 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 singlerandomInt(0, 1000000)would be equivalent - what would not israndomBytes(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,0or1), 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()whencrypto.randomBytes()errors: Some code catches the error from the callback-basedcrypto.randomBytes(size, callback)form and falls back toMath.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 ofcrypto.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
- CWE-338: Use of Cryptographically Weak PRNG
- MDN: Web Crypto API
- Node.js Crypto Documentation
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the PBKDF2 iteration count, bcrypt work factor, and Argon2 parameters used here