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
createCipherderives 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:
createCipherwas runtime-deprecated in Node 11 and removed in Node 22.0.0 (DEP0106), so on any currently supported runtime this code throwsTypeError: crypto.createCipher is not a functionrather than encrypting weakly - confirmed on Node 24.3- Uses weak MD5-based key derivation:
createCipherruns the password through a single unsaltedEVP_BytesToKeyMD5 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 hashargon2.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 matchedpromisify()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.
timingSafeEqualthrowsERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHwhen the two buffers differ in size, and the signature comes from the request, so anyone can sendX-Signature: aband 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()afterfinal(), 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()throwsUnsupported state or unable to authenticate datain 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
modulusLengthoption, and assertkey.lengthfor 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
comparecall 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
- CWE-326: Inadequate Encryption Strength
- libsodium Documentation
- NIST Key Management Guidelines
- 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
- Password Hashing Competition