CWE-316: Cleartext Storage of Sensitive Information in Memory - JavaScript/Node.js
Overview
Storing sensitive data (passwords, cryptographic keys, tokens) in memory as cleartext in JavaScript exposes it to memory dumps, debugging tools, and memory disclosure vulnerabilities. JavaScript strings are immutable in V8, making them persist in memory. Use Buffer for sensitive data, clear buffers explicitly with fill(0), and avoid logging or concatenating sensitive values.
Primary Defence: Use Buffer for passwords and keys with explicit .fill(0) in finally blocks (mutable unlike strings), implement secure key management with AES-256-GCM encryption for in-memory storage of sensitive data, use crypto.timingSafeEqual() for constant-time comparisons to prevent timing attacks, and ensure sensitive data is cleared immediately after use to prevent cleartext persistence in V8 heap dumps.
Common Vulnerable Patterns
Storing password as String
// VULNERABLE - String is immutable, persists in V8 heap
class InsecureAuth {
constructor() {
// Password stored as immutable string
this.password = '';
}
login(username, password) {
// Password stored as string - cannot be cleared
this.password = password;
// Password remains in memory indefinitely
const result = this.verifyPassword(username, password);
// Even setting to empty string doesn't clear original
this.password = '';
return result;
}
}
Storing API keys as string properties
// VULNERABLE - API keys persist in heap
class APIClient {
constructor(apiKey, apiSecret) {
// Immutable strings - visible in heap dumps
this.apiKey = apiKey;
this.apiSecret = apiSecret;
}
async makeRequest(endpoint) {
// API key exposed in memory
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
});
return response.json();
}
}
Logging sensitive data
import winston from 'winston';
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'app.log' })]
});
// VULNERABLE - Password logged to file
function login(username, password) {
logger.debug(`Login attempt: ${username} with password ${password}`);
// Password now in log files and log string objects
const success = authenticate(username, password);
logger.info(`Login result: ${success}`);
return success;
}
Not clearing Buffers
import crypto from 'crypto';
// VULNERABLE - Buffer never cleared
function hashPassword(password) {
const passwordBuffer = Buffer.from(password, 'utf8');
// Use buffer for hashing
const hash = crypto.createHash('sha256')
.update(passwordBuffer)
.digest('hex');
// Buffer never cleared - remains in memory
return hash;
}
Concatenating sensitive strings
// VULNERABLE - Creates multiple immutable copies
function buildAuthHeader(username, password) {
// Each concatenation creates new immutable string
const credentials = username + ':' + password;
const encoded = Buffer.from(credentials).toString('base64');
const header = 'Basic ' + encoded;
// Now we have 4+ copies of sensitive data in memory:
// password, credentials, encoded, header
return header;
}
Secure Patterns
Using Buffer for passwords with explicit clearing
import crypto from 'crypto';
class SecureAuth {
authenticate(username, passwordString) {
// Convert to Buffer immediately (mutable)
const passwordBuffer = Buffer.from(passwordString, 'utf8');
try {
// Use buffer for authentication
return this.verifyPassword(username, passwordBuffer);
} finally {
// Always clear buffer from memory
passwordBuffer.fill(0);
}
}
verifyPassword(username, passwordBuffer) {
try {
// Hash password buffer
const hash = crypto.createHash('sha256')
.update(passwordBuffer)
.digest();
// Get stored hash
const storedHash = this.getStoredHash(username);
// Constant-time comparison
return crypto.timingSafeEqual(hash, storedHash);
} finally {
// Clear any temporary buffers
}
}
}
Why this works:
Bufferobjects are mutable and clearable: Unlike strings, buffers can be overwritten withfill(0)to erase data- Buffers exist outside V8's managed heap: This avoids string table persistence and enables explicit clearing
finallyblock ensures cleanup: Clearing happens even if authentication throws an exceptiontimingSafeEqual()prevents timing attacks: Constant-time comparison prevents password inference through response timing- Minimizes string copies: Using
Bufferfor crypto operations avoids creating intermediate cleartext strings
Secure key management with automatic cleanup
import crypto from 'crypto';
class SecureKeyManager {
constructor(keyBuffer) {
// Store key in Buffer
this.keyBuffer = Buffer.from(keyBuffer);
this.cleared = false;
}
encrypt(plaintext) {
if (this.cleared) {
throw new Error('Key has been cleared');
}
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.keyBuffer, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Return iv + authTag + encrypted
return Buffer.concat([iv, authTag, encrypted]);
}
decrypt(ciphertext) {
if (this.cleared) {
throw new Error('Key has been cleared');
}
const iv = ciphertext.slice(0, 16);
const authTag = ciphertext.slice(16, 32);
const encrypted = ciphertext.slice(32);
const decipher = crypto.createDecipheriv('aes-256-gcm', this.keyBuffer, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
}
clear() {
if (!this.cleared && this.keyBuffer) {
this.keyBuffer.fill(0);
this.cleared = true;
}
}
}
// Usage with explicit cleanup
function processData(keyBuffer, data) {
const keyManager = new SecureKeyManager(keyBuffer);
try {
const encrypted = keyManager.encrypt(data);
// Use encrypted data
return encrypted;
} finally {
// Always clear key
keyManager.clear();
keyBuffer.fill(0);
}
}
Why this works:
- Clearable buffers: Stores keys in
Bufferobjects (mutable, outside V8 heap) that can be explicitly cleared withfill(0) - AES-256-GCM security: Authenticated encryption with 16-byte random IV and auth tag ensures confidentiality and integrity (tampering detection automatic)
- IV randomness: Random IV per encryption ensures same plaintext produces different ciphertexts, preventing pattern analysis
- Fail-fast enforcement:
clearedflag throws errors if encrypt/decrypt attempted after clearing - Complete cleanup:
finallyblocks clear intermediate buffers; usage pattern shows explicit cleanup infinallywithkeyManager.clear()andkeyBuffer.fill(0)
Express session with secure password handling
import express from 'express';
import bcrypt from 'bcrypt';
const app = express();
app.use(express.json());
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Convert password to Buffer immediately
const passwordBuffer = Buffer.from(password, 'utf8');
try {
// Get stored hash from database
const user = await User.findOne({ username });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// bcrypt handles password securely internally
// Convert buffer to string only for bcrypt (necessary)
const passwordString = passwordBuffer.toString('utf8');
const isValid = await bcrypt.compare(passwordString, user.passwordHash);
if (isValid) {
req.session.userId = user.id;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} finally {
// Clear password buffer
passwordBuffer.fill(0);
}
});
Why this works:
- Immediate buffer conversion:
Buffer.from(password, 'utf8')+finallyblock clears copy, preventing propagation through app logic (originalreq.body.passwordmay persist in Express) - BCrypt security:
compare()hashes input with same salt as stored hash, then constant-time comparison prevents timing attacks; auto-generates salts (no separate management) - Configurable work factor: Default 10 rounds (2^10 = 1,024 iterations) makes brute-force expensive
- Session-based auth:
req.session.userId = user.idenables stateful authentication without password re-transmission - Enumeration prevention: Generic "Invalid credentials" for both non-existent users and wrong passwords prevents username discovery
Secure JWT token handler
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
class SecureJWTHandler {
constructor(secretBuffer) {
// Store secret in Buffer
this.secretBuffer = Buffer.from(secretBuffer);
this.cleared = false;
}
createToken(payload) {
if (this.cleared) {
throw new Error('Secret has been cleared');
}
// jwt.sign accepts Buffer as secret
return jwt.sign(payload, this.secretBuffer, {
algorithm: 'HS256',
expiresIn: '1h'
});
}
verifyToken(token) {
if (this.cleared) {
throw new Error('Secret has been cleared');
}
return jwt.verify(token, this.secretBuffer, {
algorithms: ['HS256']
});
}
clear() {
if (!this.cleared && this.secretBuffer) {
this.secretBuffer.fill(0);
this.cleared = true;
}
}
}
// Usage
const secretBuffer = crypto.randomBytes(32);
const jwtHandler = new SecureJWTHandler(secretBuffer);
try {
const token = jwtHandler.createToken({ userId: 123 });
// Use token
const payload = jwtHandler.verifyToken(token);
console.log('User ID:', payload.userId);
} finally {
// Clear secret
jwtHandler.clear();
secretBuffer.fill(0);
}
Why this works:
- Clearable secret: Stores JWT signing secret in
Buffer(mutable) that can be explicitly cleared withfill(0), preventing persistence in memory - HMAC-SHA256 security:
jsonwebtokenlibrary creates cryptographically secure signatures preventing tampering;expiresIn: '1h'adds automatic expiration - Algorithm protection:
algorithms: ['HS256']inverify()prevents algorithm confusion attacks - Fail-fast enforcement:
clearedflag throws errors if tokens created/verified after secret clearing - Usage pattern: Generate 32-byte random secret (
crypto.randomBytes(32)), use in try-finally, clear both handler and original buffer on shutdown
Secure credential storage with crypto timing
import crypto from 'crypto';
class SecureCredentialStore {
constructor() {
this.credentials = new Map();
}
setCredential(key, valueString) {
// Convert to Buffer
const valueBuffer = Buffer.from(valueString, 'utf8');
// Store Buffer (mutable)
this.credentials.set(key, valueBuffer);
}
verifyCredential(key, inputString) {
const stored = this.credentials.get(key);
if (!stored) {
return false;
}
// Convert input to Buffer
const inputBuffer = Buffer.from(inputString, 'utf8');
try {
// Constant-time comparison (prevents timing attacks)
if (stored.length !== inputBuffer.length) {
return false;
}
return crypto.timingSafeEqual(stored, inputBuffer);
} finally {
// Clear input buffer
inputBuffer.fill(0);
}
}
clearCredential(key) {
const credential = this.credentials.get(key);
if (credential) {
// Clear buffer
credential.fill(0);
this.credentials.delete(key);
}
}
clearAll() {
// Clear all stored credentials
for (const [key, buffer] of this.credentials) {
buffer.fill(0);
}
this.credentials.clear();
}
}
// Usage
const store = new SecureCredentialStore();
try {
store.setCredential('apiKey', 'secret-key-123');
// Verify credential
const isValid = store.verifyCredential('apiKey', 'secret-key-123');
console.log('Valid:', isValid);
} finally {
// Always clear when done
store.clearAll();
}
Why this works:
- Mutable storage:
Bufferobjects (not immutable strings) enable explicit clearing withfill(0)when credentials no longer needed - Timing-safe comparison:
crypto.timingSafeEqual()takes constant time regardless of where first byte difference occurs, preventing timing attacks via response time measurements - Length check trade-off: Pre-comparison length check required (function needs equal-length buffers) leaks credential length, but acceptable as length typically not secret
- Cleanup methods:
clearCredential()zeros before removal;clearAll()ensures all credentials zeroed on shutdown/logout - Use cases: In-memory credential caches, API key stores, session managers requiring temporary secure storage with explicit clearing
Next.js API route with secure password handling
// pages/api/auth/login.js
import bcrypt from 'bcrypt';
import { connectToDatabase } from '../../../lib/db';
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { username, password } = req.body;
// Convert password to Buffer
const passwordBuffer = Buffer.from(password, 'utf8');
try {
const { db } = await connectToDatabase();
const user = await db.collection('users').findOne({ username });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare with bcrypt (handles clearing internally)
const passwordString = passwordBuffer.toString('utf8');
const isValid = await bcrypt.compare(passwordString, user.passwordHash);
if (isValid) {
// Create session (using httpOnly cookies)
const token = createSecureSession(user.id);
res.setHeader('Set-Cookie', `token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/`);
res.status(200).json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} finally {
// Clear password buffer
passwordBuffer.fill(0);
}
}
function createSecureSession(userId) {
// Use secure JWT or session token
const secretBuffer = Buffer.from(process.env.JWT_SECRET, 'utf8');
try {
const jwtHandler = new SecureJWTHandler(secretBuffer);
const token = jwtHandler.createToken({ userId });
jwtHandler.clear();
return token;
} finally {
secretBuffer.fill(0);
}
}
Why this works:
- Buffer clearing: Converts password to
Bufferimmediately;finallyblock clears to prevent propagation (originalreq.body.passwordmay exist briefly in Next.js) - BCrypt verification:
compare()auto-extracts salt from stored hash + constant-time comparison prevents timing attacks - Secure cookies:
httpOnlyprevents XSS access;Securerequires HTTPS;SameSite=Strictblocks CSRF;Path=/available to all routes - JWT integration:
createSecureSession()loads secret asBufferfrom env var, creates token, clears secret immediately; outer try-finally ensures clearing on failure - Defense-in-depth: Combines secure password handling, bcrypt verification, httpOnly cookies for Next.js API route authentication
Password hashing with automatic cleanup
import crypto from 'crypto';
import { promisify } from 'util';
const pbkdf2 = promisify(crypto.pbkdf2);
async function hashPassword(passwordString) {
const passwordBuffer = Buffer.from(passwordString, 'utf8');
const salt = crypto.randomBytes(16);
try {
// Use PBKDF2 for key derivation
const hash = await pbkdf2(passwordBuffer, salt, 600000, 64, 'sha512');
// Combine salt + hash
const combined = Buffer.concat([salt, hash]);
// Return as base64 for storage
return combined.toString('base64');
} finally {
// Clear password buffer
passwordBuffer.fill(0);
}
}
async function verifyPassword(passwordString, storedHash) {
const passwordBuffer = Buffer.from(passwordString, 'utf8');
const combined = Buffer.from(storedHash, 'base64');
try {
// Extract salt and hash
const salt = combined.slice(0, 16);
const originalHash = combined.slice(16);
// Hash input password with same salt
const inputHash = await pbkdf2(passwordBuffer, salt, 600000, 64, 'sha512');
// Constant-time comparison
return crypto.timingSafeEqual(originalHash, inputHash);
} finally {
// Clear buffers
passwordBuffer.fill(0);
}
}
Why this works:
- Computational cost: PBKDF2 with 600,000 iterations, 16-byte random salt, SHA-512 creates 64-byte derived key - makes brute-force expensive (600k SHA-512 hashes per guess, OWASP 2023 recommendation)
- Memory clearing: Password converted to
Bufferand cleared infinallyto minimize cleartext exposure - Rainbow table prevention: Random salt per password ensures identical passwords produce different hashes
- Storage simplification: Concatenates salt+hash as single base64 buffer; during verification, extracts salt (first 16 bytes) and compares using
crypto.timingSafeEqual()(constant-time) - Use case: More secure than simple SHA-256 (too fast); recommended for custom auth without bcrypt/Argon2 libraries
Secure environment variable handling
import crypto from 'crypto';
class SecureConfig {
constructor() {
this.secrets = new Map();
this.loadSecrets();
}
loadSecrets() {
// Load secrets from environment
const secretKeys = ['DB_PASSWORD', 'API_KEY', 'JWT_SECRET'];
for (const key of secretKeys) {
const value = process.env[key];
if (value) {
// Store as Buffer
this.secrets.set(key, Buffer.from(value, 'utf8'));
// Clear from process.env (won't affect actual env vars)
delete process.env[key];
}
}
}
getSecret(key) {
const secret = this.secrets.get(key);
if (!secret) {
throw new Error(`Secret ${key} not found`);
}
// Return copy to prevent external modification
return Buffer.from(secret);
}
clearSecrets() {
for (const [key, buffer] of this.secrets) {
buffer.fill(0);
}
this.secrets.clear();
}
}
// Global config instance
const config = new SecureConfig();
// Cleanup on process exit
process.on('exit', () => {
config.clearSecrets();
});
process.on('SIGINT', () => {
config.clearSecrets();
process.exit(0);
});
// Usage
function connectToDatabase() {
const passwordBuffer = config.getSecret('DB_PASSWORD');
try {
const passwordString = passwordBuffer.toString('utf8');
// Connect to database
} finally {
passwordBuffer.fill(0);
}
}
Why this works:
- Runtime removal: Loads secrets into
Bufferobjects inMap, then deletes fromprocess.envto reduce attack surface (doesn't affect parent process, but removes from Node.js runtime) - Explicit clearing:
fill(0)clears buffers on shutdown (exit,SIGINThandlers) to prevent secrets lingering in memory - Encapsulation:
getSecret()returns copy (Buffer.from()) to prevent external modification or reference retention - Container-friendly: Important for Docker/Kubernetes where secrets injected via environment variables should be cleared ASAP
- Limitations: Cleanup handlers won't run for
SIGKILL; centralizedSecureConfigclass simplifies audit and maintenance
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced