Skip to content

CWE-326: Inadequate Encryption Strength - JavaScript/Node.js

Overview

Inadequate Encryption Strength in JavaScript/Node.js applications occurs when developers use weak cryptographic algorithms, insufficient key sizes, or deprecated ciphers that fail to protect sensitive data against modern attacks. The Node.js crypto module exposes current and legacy algorithms side by side: it will hand you DES or MD5 if you name them, so the choice of algorithm and key size is the application's to get right.

Common JavaScript Vulnerability Scenarios:

  • Using DES or RC4 instead of AES-256
  • Implementing password hashing with MD5 or SHA-1
  • Using weak cipher modes like ECB
  • Insufficient key derivation iterations for PBKDF2
  • Using deprecated or custom encryption implementations
  • Weak random number generation for cryptographic keys

JavaScript/Node.js Cryptographic Landscape:

  • crypto (built-in): Node.js native cryptography module
  • bcrypt: Industry-standard password hashing library
  • argon2: Modern password hashing (PHC winner)
  • jose: JSON Web Encryption/Signature library
  • sodium-native: Libsodium bindings for Node.js

Framework-Specific Considerations:

  • Express: Use middleware for encryption/decryption
  • Fastify: Leverage plugins for cryptographic operations
  • Next.js: Secure API routes with proper encryption
  • NestJS: Use dependency injection for crypto services

Primary Defence: Use crypto.createCipheriv() with aes-256-gcm for encryption, bcrypt or argon2 for password hashing, and avoid deprecated algorithms like DES, RC4, or MD5.

Common Vulnerable Patterns

Using DES Encryption

const crypto = require('crypto');

// VULNERABLE - DES in ECB mode, and createCipher derives the key with unsalted MD5
class WeakEncryptionService {
  constructor(key) {
    // DES has only 56-bit effective key strength
    this.key = Buffer.from(key).slice(0, 8); // DES requires 8-byte key
    this.algorithm = 'des-ecb'; // ECB mode is also vulnerable
  }

  encryptSSN(ssn) {
    const cipher = crypto.createCipher(this.algorithm, this.key);
    let encrypted = cipher.update(ssn, 'utf8', 'base64');
    encrypted += cipher.final('base64');
    return encrypted;
  }

  decryptSSN(encryptedSSN) {
    const decipher = crypto.createDecipher(this.algorithm, this.key);
    let decrypted = decipher.update(encryptedSSN, 'base64', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
  }
}

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

const encryptor = new WeakEncryptionService('weak_key');

app.post('/api/users', (req, res) => {
  const { ssn } = req.body;
  const encryptedSSN = encryptor.encryptSSN(ssn);

  // Store encryptedSSN in database
  res.json({ status: 'created', encryptedSSN });
});

Why this is vulnerable:

  • DES provides only 56-bit security (easily broken)
  • ECB mode reveals patterns in encrypted data
  • createCipher derives the key from the password with a single unsalted MD5 pass, and was removed outright in Node 22.0.0 (DEP0106) - this code no longer runs on a supported runtime, so a codebase still containing it is either on an end-of-life Node or already broken
  • No authentication or integrity protection

MD5 for Password Hashing

const crypto = require('crypto');

class WeakPasswordService {
  hashPassword(password) {
    // MD5 is cryptographically broken
    return crypto.createHash('md5')
      .update(password)
      .digest('hex');
  }

  verifyPassword(password, hash) {
    const computedHash = this.hashPassword(password);
    return computedHash === hash; // Timing attack vulnerable
  }
}

// Express authentication
const express = require('express');
const app = express();
app.use(express.json());

const passwordService = new WeakPasswordService();
const users = new Map(); // Simulated database

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

  // VULNERABLE - MD5 without salt
  const passwordHash = passwordService.hashPassword(password);

  users.set(username, { username, passwordHash });
  res.json({ status: 'registered' });
});

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

  if (!user || !passwordService.verifyPassword(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  res.json({ status: 'logged_in' });
});

Why this is vulnerable:

  • MD5 has collision vulnerabilities
  • No salt means identical passwords produce identical hashes
  • Fast hashing enables brute-force attacks
  • Rainbow tables can reverse common passwords
  • String comparison is vulnerable to timing attacks

Weak Key Derivation for AES

// VULNERABLE - Weak Key Derivation for AES
const crypto = require('crypto');

class WeakKeyDerivation {
  constructor(password) {
    // Weak: Direct password use without proper derivation
    this.key = crypto.createHash('sha256')
      .update(password)
      .digest();
  }

  encrypt(plaintext) {
    // AES-256 but with weakly derived key
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv);

    let encrypted = cipher.update(plaintext, 'utf8', 'base64');
    encrypted += cipher.final('base64');

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

  decrypt(ciphertext) {
    const [ivBase64, encrypted] = ciphertext.split(':');
    const iv = Buffer.from(ivBase64, 'base64');

    const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv);
    let decrypted = decipher.update(encrypted, 'base64', 'utf8');
    decrypted += decipher.final('utf8');

    return decrypted;
  }
}

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

const encryptor = new WeakKeyDerivation('user_password');

fastify.post('/api/encrypt', async (request, reply) => {
  const { data } = request.body;
  const encrypted = encryptor.encrypt(data);

  return { encrypted };
});

Why this is vulnerable:

  • Single SHA-256 hash is not sufficient for key derivation
  • No salt means same password always produces same key
  • No iteration count to slow down brute-force
  • CBC mode without authentication (vulnerable to tampering)

Low Iteration PBKDF2

// VULNERABLE - Low Iteration PBKDF2
const crypto = require('crypto');

class WeakPBKDF2 {
  constructor() {
    this.iterations = 1000; // Too low!
    this.keylen = 16; // Only 128 bits
    this.digest = 'sha1'; // Deprecated
    this.salt = 'fixed_salt'; // Fixed salt!
  }

  deriveKey(password) {
    return crypto.pbkdf2Sync(
      password,
      this.salt,
      this.iterations,
      this.keylen,
      this.digest
    );
  }

  encrypt(password, plaintext) {
    const key = this.deriveKey(password);
    const iv = crypto.randomBytes(16);

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

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

// Next.js API route with weak PBKDF2
export default async function handler(req, res) {
  const weakPbkdf2 = new WeakPBKDF2();

  if (req.method === 'POST') {
    const { password, data } = req.body;
    const encrypted = weakPbkdf2.encrypt(password, data);

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

Why this is vulnerable:

  • Only 1000 iterations (OWASP recommends 600,000+ for PBKDF2, updated 2023)
  • Fixed salt defeats the purpose of salting
  • AES-128 instead of AES-256 - a smaller margin rather than a break; unlike the other items here, AES-128 is still 128-bit strength
  • SHA-1 instead of SHA-256
  • CBC mode without authentication

Using createCipher (Deprecated)

const crypto = require('crypto');

// VULNERABLE - createCipher is deprecated (removed in Node 22) and derives the key with unsalted MD5
function weakEncrypt(text, password) {
  const cipher = crypto.createCipher('aes192', password);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return encrypted;
}

function weakDecrypt(encrypted, password) {
  const decipher = crypto.createDecipher('aes192', password);
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

// Express route using deprecated API
const express = require('express');
const app = express();

app.post('/api/secure-message', express.json(), (req, res) => {
  const { message, password } = req.body;
  const encrypted = weakEncrypt(message, password);

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

Why this is vulnerable:

  • createCipher was runtime-deprecated in Node 11 and removed in Node 22.0.0 (DEP0106), so on any currently supported runtime this code throws TypeError: crypto.createCipher is not a function rather than encrypting weakly - confirmed on Node 24.3
  • Uses weak MD5-based key derivation: createCipher runs the password through a single unsalted EVP_BytesToKey MD5 pass, so the key is as guessable as the password and identical passwords produce identical keys
  • No proper IV management: the IV is derived from the same password, so it is fixed per password rather than random per message
  • CBC mode without authentication
  • AES-192 is not the weakness here. AES-192 carries a full 192-bit security strength; raising it to AES-256 leaves every problem above in place

Custom Encryption Implementation

// NEVER DO THIS: Custom encryption
class CustomEncryption {
  constructor(key) {
    this.key = key;
  }

  // Simple XOR encryption - completely insecure
  encrypt(plaintext) {
    let encrypted = '';
    for (let i = 0; i < plaintext.length; i++) {
      const charCode = plaintext.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length);
      encrypted += String.fromCharCode(charCode);
    }
    return Buffer.from(encrypted).toString('base64');
  }

  decrypt(ciphertext) {
    const encrypted = Buffer.from(ciphertext, 'base64').toString();
    let decrypted = '';
    for (let i = 0; i < encrypted.length; i++) {
      const charCode = encrypted.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length);
      decrypted += String.fromCharCode(charCode);
    }
    return decrypted;
  }
}

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

@Controller('api')
export class CryptoController {
  private customEncryption = new CustomEncryption('secretkey');

  @Post('encrypt')
  encrypt(@Body() body: { data: string }) {
    const encrypted = this.customEncryption.encrypt(body.data);
    return { encrypted };
  }
}

Why this is vulnerable:

  • Custom cryptography is almost always broken
  • XOR cipher is trivially breakable
  • No authentication
  • Violates Kerckhoffs's principle

Weak Random Number Generation

// VULNERABLE - Using Math.random for cryptographic purposes
function generateWeakToken() {
  return Math.random().toString(36).substring(2);
}

function generateWeakKey() {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let key = '';
  for (let i = 0; i < 32; i++) {
    key += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return key;
}

// Express session with weak token generation
const express = require('express');
const session = require('express-session');

const app = express();
app.use(session({
  genid: () => generateWeakToken(), // Predictable session IDs!
  secret: generateWeakKey(), // Weak secret!
  resave: false,
  saveUninitialized: true
}));

Why this is vulnerable:

  • Math.random() is not cryptographically secure
  • Its output is predictable, so the generated session IDs can be guessed
  • Guessing a live session ID is session hijacking

SHA-1 for HMAC

const crypto = require('crypto');

class WeakHMAC {
  constructor(secret) {
    this.secret = secret;
    this.algorithm = 'sha1'; // Deprecated
  }

  sign(data) {
    return crypto
      .createHmac(this.algorithm, this.secret)
      .update(data)
      .digest('hex');
  }

  verify(data, signature) {
    const expected = this.sign(data);
    return expected === signature; // Timing attack vulnerable
  }
}

// Express webhook endpoint
const express = require('express');
const app = express();

const hmac = new WeakHMAC('webhook_secret');

app.post('/api/webhook', express.json(), (req, res) => {
  const signature = req.headers['x-signature'];
  const payload = JSON.stringify(req.body);

  if (!hmac.verify(payload, signature)) {
    return res.status(403).json({ error: 'Invalid signature' });
  }

  // Process webhook
  res.json({ status: 'processed' });
});

Why this is vulnerable:

  • SHA-1 has known collision vulnerabilities
  • String comparison is vulnerable to timing attacks
  • Industry standards require SHA-256 or stronger

Secure Patterns

AES-256-GCM Encryption

// SECURE - AES-256-GCM: authenticated encryption with a fresh nonce per message
const crypto = require('crypto');

class SecureAESGCMEncryption {
  constructor(masterKey) {
    // Master key should be 32 bytes (256 bits) for AES-256
    if (!masterKey || masterKey.length !== 32) {
      throw new Error('Master key must be 32 bytes for AES-256');
    }
    this.masterKey = masterKey;
  }

  static generateKey() {
    // Generate cryptographically secure random key
    return crypto.randomBytes(32);
  }

  encryptSSN(ssn) {
    // Generate random 12-byte IV (recommended for GCM)
    const iv = crypto.randomBytes(12);

    // Create cipher with AES-256-GCM
    const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, iv);

    // Add authenticated associated data (AAD)
    const aad = Buffer.from('SSN_ENCRYPTION_V1', 'utf8');
    cipher.setAAD(aad);

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

    // Get authentication tag
    const authTag = cipher.getAuthTag();

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

    return combined.toString('base64');
  }

  decryptSSN(encryptedSSN) {
    const combined = Buffer.from(encryptedSSN, 'base64');

    // Extract components
    const iv = combined.slice(0, 12);
    const authTag = combined.slice(12, 28); // 16 bytes
    const encrypted = combined.slice(28);

    // Create decipher
    const decipher = crypto.createDecipheriv('aes-256-gcm', this.masterKey, iv);

    // Set AAD and auth tag
    const aad = Buffer.from('SSN_ENCRYPTION_V1', 'utf8');
    decipher.setAAD(aad);
    decipher.setAuthTag(authTag);

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

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

// Short usage
const key = SecureAESGCMEncryption.generateKey();
const aes = new SecureAESGCMEncryption(key);
const encrypted = aes.encryptSSN('123-45-6789');
const decrypted = aes.decryptSSN(encrypted);

Why this works:

  • AES-256-GCM combines confidentiality (AES-256) with integrity (Galois/Counter Mode) in one operation
  • 256-bit key requires 2^256 trials to brute-force - computationally infeasible
  • Node.js crypto module uses OpenSSL's implementations rather than hand-written ones
  • GCM's 128-bit authentication tag detects any modification to ciphertext, IV, or AAD, so decryption fails instead of returning plaintext
  • 12-byte IV (96 bits) is GCM-recommended size, must be unique per encryption
  • crypto.randomBytes(12) provides OS-level cryptographic randomness
  • Authenticated Associated Data ("SSN_ENCRYPTION_V1") binds version info to ciphertext
  • Prevents moving ciphertexts between contexts (e.g., swapping encrypted SSNs)
  • Pattern bundles IV + auth tag + ciphertext for synchronized storage
  • Attackers cannot modify or forge ciphertexts without the encryption key

Bcrypt for Password Hashing

const bcrypt = require('bcrypt');
const express = require('express');

class SecurePasswordService {
  constructor() {
    // Cost factor 12 = 2^12 = 4096 iterations
    this.saltRounds = 12;
  }

  async hashPassword(password) {
    this.validatePasswordStrength(password);

    // Bcrypt automatically generates salt
    return await bcrypt.hash(password, this.saltRounds);
  }

  async verifyPassword(password, hash) {
    // Timing-safe comparison
    return await bcrypt.compare(password, hash);
  }

  validatePasswordStrength(password) {
    if (password.length < 12) {
      throw new Error('Password must be at least 12 characters');
    }
    if (!/[A-Z]/.test(password)) {
      throw new Error('Password must contain uppercase letter');
    }
    if (!/[a-z]/.test(password)) {
      throw new Error('Password must contain lowercase letter');
    }
    if (!/\d/.test(password)) {
      throw new Error('Password must contain digit');
    }
  }
}

// Short usage
const passwordService = new SecurePasswordService();
const hash = await passwordService.hashPassword('Str0ngPassw0rd!');
const ok = await passwordService.verifyPassword('Str0ngPassw0rd!', hash);

Why this works:

  • Bcrypt is intentionally slow - makes brute-force attacks economically infeasible
  • Cost factor 12 = 2^12 (4,096) iterations, adjustable as hardware improves
  • Unlike SHA-256 (millions/sec on GPUs), bcrypt's Blowfish key schedule needs random access to a ~4 KB working state per guess, which is awkward on a GPU. Note that this is not memory-hardness: the 4 KB is fixed whatever the cost factor, because the cost parameter multiplies rounds rather than memory. Argon2id and scrypt make the memory requirement itself tunable, which is what actually constrains an attacker running many guesses in parallel, and OWASP now treats bcrypt as the option for legacy systems where neither is available
  • bcrypt.hash() auto-generates 128-bit random salt per password
  • Prevents rainbow table attacks and identical passwords producing same hash
  • Salt + hash combined in one string: $2b$12$[salt][hash] - no separate salt column to store or keep in sync
  • bcrypt.compare() uses constant-time comparison - prevents timing attacks
  • Async implementation avoids blocking Node.js event loop

Two things this example does not do, and a login route needs both. Compare against a real stored hash for a user that does not exist, so the unknown-user branch takes the same time as a wrong password - a bcrypt.compare() skipped entirely returns in microseconds and answers "does this username exist". And return one generic message for both failures, so the response body does not answer it either.

Argon2id for Password Hashing

const argon2 = require('argon2');
const { randomBytes } = require('crypto');

class SecureArgon2Service {
  constructor() {
    this.options = {
      type: argon2.argon2id, // Hybrid mode (best security)
      memoryCost: 65536, // 64 MB
      timeCost: 3, // 3 iterations
      parallelism: 4 // 4 threads
    };
  }

  async hashPassword(password) {
    return await argon2.hash(password, this.options);
  }

  async verifyPassword(password, hash) {
    try {
      return await argon2.verify(hash, password);
    } catch (error) {
      return false;
    }
  }

  async needsRehash(hash) {
    return argon2.needsRehash(hash, this.options);
  }
}

// Short usage
const argon2Service = new SecureArgon2Service();
const hash = await argon2Service.hashPassword('Str0ngPassw0rd!');
const ok = await argon2Service.verifyPassword('Str0ngPassw0rd!', hash);

Why this works:

  • Argon2 won the 2015 Password Hashing Competition; Argon2id is the recommended variant for password hashing
  • Hybrid mode combines Argon2i (side-channel resistant) with Argon2d (GPU resistant), balancing both
  • 64 MB memory cost makes GPU attacks expensive (limited GPU memory bandwidth)
  • Attackers must allocate 64 MB per parallel guess, which limits parallelization far more than a fast hash or an older password-hashing configuration does
  • Time cost (3 iterations) + parallelism (4 threads) balance security vs performance
  • argon2.verify() auto-extracts parameters from stored hash
  • argon2.needsRehash() detects outdated parameters
  • Auto-rehashing during login upgrades database without forcing password resets
  • Hash includes all parameters + salt - no additional storage needed

PBKDF2 with High Iteration Count

const crypto = require('crypto');
const { promisify } = require('util');

const pbkdf2Async = promisify(crypto.pbkdf2);

class SecurePBKDF2Service {
  constructor() {
    this.iterations = 600000; // OWASP minimum where PBKDF2-HMAC-SHA256 is required
    this.keylen = 32; // 256 bits
    this.digest = 'sha256';
  }

  async hashPassword(password) {
    // Generate random salt
    const salt = crypto.randomBytes(32);

    // Derive key
    const hash = await pbkdf2Async(
      password,
      salt,
      this.iterations,
      this.keylen,
      this.digest
    );

    // Combine salt and hash for storage
    return {
      hash: hash.toString('base64'),
      salt: salt.toString('base64'),
      iterations: this.iterations,
      digest: this.digest
    };
  }

  async verifyPassword(password, stored) {
    const salt = Buffer.from(stored.salt, 'base64');

    const hash = await pbkdf2Async(
      password,
      salt,
      stored.iterations,
      this.keylen,
      stored.digest
    );

    const storedHash = Buffer.from(stored.hash, 'base64');

    // Timing-safe comparison
    return crypto.timingSafeEqual(hash, storedHash);
  }

  encodeForStorage(hashedPassword) {
    return JSON.stringify(hashedPassword);
  }

  decodeFromStorage(encoded) {
    return JSON.parse(encoded);
  }
}

// Short usage
const pbkdf2Service = new SecurePBKDF2Service();
const stored = await pbkdf2Service.hashPassword('Str0ngPassw0rd!');
const ok = await pbkdf2Service.verifyPassword('Str0ngPassw0rd!', stored);

Why this works:

  • 600,000 iterations aligns with current OWASP guidance where PBKDF2-HMAC-SHA256 is required
  • Requires 600,000 HMAC-SHA256 operations per password guess, multiplying the cost of an offline guessing run by the same factor
  • 256-bit random salt (32 bytes) ensures cryptographic uniqueness per password
  • Prevents rainbow table attacks and precomputation
  • Salt stored with hash but doesn't need secrecy - purpose is uniqueness
  • SHA-256 PRF (not deprecated SHA-1) ensures cryptographic strength
  • crypto.timingSafeEqual() examines every byte regardless of differences, so the response time does not reveal how much of the hash matched
  • promisify() enables clean async/await code
  • Stores hash + salt + parameters for future upgrades
  • While Argon2id, bcrypt, or scrypt are usually preferred for password storage, PBKDF2 remains widely standardized and useful where required by platform or compliance constraints

HMAC-SHA256 for Message Authentication

const crypto = require('crypto');

class SecureHMACService {
  constructor(secret) {
    if (!secret || secret.length < 32) {
      throw new Error('HMAC secret must be at least 32 bytes');
    }
    this.secret = secret;
    this.algorithm = 'sha256';
    this.tolerance = 300; // 5 minutes in seconds
  }

  static generateSecret() {
    return crypto.randomBytes(32);
  }

  generateSignature(data, timestamp) {
    const message = `${timestamp}.${data}`;

    return crypto
      .createHmac(this.algorithm, this.secret)
      .update(message)
      .digest('hex');
  }

  verifySignature(data, signature, timestamp) {
    // Check timestamp (replay protection)
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - timestamp) > this.tolerance) {
      return false;
    }

    // Compute expected signature
    const expected = Buffer.from(this.generateSignature(data, timestamp), 'hex');

    // The signature is attacker-supplied, so its type and its length are too.
    // Buffer.from throws ERR_INVALID_ARG_TYPE for the number or object a JSON
    // body can carry, and timingSafeEqual throws
    // ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on unequal buffers - either would
    // turn a forged request into a 500 instead of a 403.
    if (typeof signature !== 'string') {
      return false;
    }
    const provided = Buffer.from(signature, 'hex');
    if (provided.length !== expected.length) {
      return false;
    }

    // Timing-safe comparison
    return crypto.timingSafeEqual(expected, provided);
  }
}

// Short usage
const hmac = new SecureHMACService(SecureHMACService.generateSecret());
const timestamp = Math.floor(Date.now() / 1000);
const sig = hmac.generateSignature('payload', timestamp);
const ok = hmac.verifySignature('payload', sig, timestamp);

Why this works:

  • HMAC construction combines secret key + data via hash function
  • Inner/outer padding with XOR prevents length extension attacks
  • Impossible to forge signatures without secret key, even observing many valid pairs
  • SHA-256 gives 256-bit preimage resistance and 128-bit collision resistance, though HMAC rests on the hash behaving as a pseudorandom function rather than on either
  • Avoids vulnerabilities in deprecated SHA-1 or MD5
  • Secret key must be ≥32 bytes (256 bits) to match SHA-256 security
  • crypto.randomBytes(32) generates cryptographically secure keys from OS entropy
  • Timestamp inclusion limits signature validity to a 5-minute window, so an intercepted message cannot be replayed once it falls outside the tolerance
  • crypto.timingSafeEqual() constant-time comparison prevents timing attacks
  • The length check ahead of it is not optional. timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when the two buffers differ in size, and the signature comes from the request, so anyone can send X-Signature: ab and get an unhandled exception out of the webhook route instead of a rejection. Comparing lengths first leaks only the length, which the attacker already knows
  • Suitable for webhooks, API signing, and message authentication

Scrypt for Key Derivation

const crypto = require('crypto');
const { promisify } = require('util');

const scryptAsync = promisify(crypto.scrypt);

class SecureScryptService {
  constructor() {
    this.keyLength = 32; // 256 bits
    this.saltLength = 32; // 256 bits
    this.options = {
      N: 16384, // CPU/memory cost (2^14)
      r: 8,     // Block size
      p: 1,     // Parallelization
      maxmem: 32 * 1024 * 1024 // 32 MB
    };
  }

  async deriveKey(password) {
    const salt = crypto.randomBytes(this.saltLength);

    const key = await scryptAsync(
      password,
      salt,
      this.keyLength,
      this.options
    );

    return {
      key: key.toString('base64'),
      salt: salt.toString('base64'),
      ...this.options
    };
  }

  async verifyKey(password, stored) {
    const salt = Buffer.from(stored.salt, 'base64');

    const key = await scryptAsync(
      password,
      salt,
      this.keyLength,
      {
        N: stored.N,
        r: stored.r,
        p: stored.p,
        maxmem: stored.maxmem
      }
    );

    const storedKey = Buffer.from(stored.key, 'base64');

    return crypto.timingSafeEqual(key, storedKey);
  }
}

// Short usage
const scryptService = new SecureScryptService();
const derived = await scryptService.deriveKey('StrongPassword!');
const ok = await scryptService.verifyKey('StrongPassword!', derived);

Why this works:

  • Scrypt resists GPU/ASIC/FPGA attacks by requiring large memory + computation
  • N=16384 (2^14) requires ~16 MB memory per derivation
  • Parallel attacks expensive - each attempt needs dedicated RAM
  • Unlike bcrypt, cannot parallelize easily on GPUs despite computational expense
  • Generates large pseudorandom vector requiring fast memory + random access
  • Forces attackers to either allocate full memory (expensive) or recompute (slow)
  • r=8 (block size) and p=1 (parallelization) balance security vs performance
  • Unique random salt per derivation - identical passwords produce different keys
  • The salt and the N/r/p parameters are returned alongside the key, so a later verification - or a decryption, if you feed the 256-bit output to createCipheriv('aes-256-gcm', ...) - can reproduce it
  • Node.js built-in crypto.scrypt() provides native performance
  • Well-suited for password-based key derivation. For password storage, prefer Argon2id: it is the current OWASP first choice and its library encodes the parameters into the hash string for you

Testing

A scanner confirms the weak algorithm is gone. It cannot confirm the replacement works, and a key-strength change is unusually good at passing review while breaking data that already exists. Assert each of these:

  • Ciphertext round-trips. Encrypt, then decrypt through a separately created decipher, and compare against the original plaintext. The auth tag is only available from cipher.getAuthTag() after final(), so a test that reuses the cipher object will pass while the stored ciphertext can never be authenticated.
  • Tampering is rejected. Flip one byte of the ciphertext, one byte of the tag, and one byte of the IV, and confirm decipher.final() throws Unsupported state or unable to authenticate data in each case rather than returning plaintext.
  • The key size actually applied. Assert the modulus length of a generated RSA key is 3072 rather than trusting the modulusLength option, and assert key.length for symmetric keys.
  • Data encrypted before the change still decrypts. Keep a fixture encrypted under the old algorithm and assert the dual-read path returns the original plaintext. This is the test that fails in production if it is missing.
  • Old password hashes still verify, and are upgraded on use. Assert that a correct password checked against a stored legacy hash resolves true, that the stored hash is then rewritten at the current cost, and that a wrong password still resolves false at both stages. bcrypt.getRounds() on the stored hash tells you whether the rewrite happened.
  • Password hashing is slow enough. Time one compare call and assert it falls in the intended range (roughly 250-500ms). A cost factor lowered to speed up the test suite tends to reach production.

Additional Resources