Skip to content

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 and cannot be overwritten in place, so they can remain until garbage collection and may appear in heap snapshots. Use Buffer for sensitive data where APIs support it, 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 where the surrounding APIs allow it, minimize string copies, use crypto.timingSafeEqual() for fixed-length secret comparisons, and keep secrets out of logs, heap snapshots, crash reports, and long-lived process state.

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:

  • Buffer objects are mutable and clearable: Unlike strings, buffers can be overwritten with fill(0) to erase data
  • Buffers exist outside V8's managed heap: This avoids string table persistence and enables explicit clearing
  • finally block ensures cleanup: Clearing happens even if authentication throws an exception
  • timingSafeEqual() reduces timing leakage for equal-length values: Use it after normalizing or safely handling length differences
  • Minimizes string copies: Using Buffer for 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(12);
        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, 12);
        const authTag = ciphertext.slice(12, 28);
        const encrypted = ciphertext.slice(28);

        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 Buffer objects (mutable, outside V8 heap) that can be explicitly cleared with fill(0)
  • AES-256-GCM security: Authenticated encryption with a unique nonce and auth tag provides confidentiality and integrity; a 96-bit nonce is the common GCM recommendation
  • Nonce uniqueness: A fresh random 96-bit nonce per encryption prevents nonce reuse under the same key
  • Fail-fast enforcement: cleared flag throws errors if encrypt/decrypt attempted after clearing
  • Complete cleanup: finally blocks clear intermediate buffers; usage pattern shows explicit cleanup in finally with keyManager.clear() and keyBuffer.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 performs password hashing; the input string still exists in process memory
        // 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') + finally block clears copy, preventing propagation through app logic (original req.body.password may persist in Express)
  • BCrypt security: compare() verifies the password against the stored salted hash; this protects stored credentials but does not guarantee that temporary JavaScript strings are cleared from memory
  • Configurable work factor: Default 10 rounds (2^10 = 1,024 iterations) makes brute-force expensive
  • Session-based auth: req.session.userId = user.id enables 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 with fill(0), preventing persistence in memory
  • HMAC-SHA256 security: jsonwebtoken library creates cryptographically secure signatures preventing tampering; expiresIn: '1h' adds automatic expiration
  • Algorithm protection: algorithms: ['HS256'] in verify() prevents algorithm confusion attacks
  • Fail-fast enforcement: cleared flag 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', '<redacted-api-key>');

    // Verify credential
    const isValid = store.verifyCredential('apiKey', '<redacted-api-key>');
    console.log('Valid:', isValid);

} finally {
    // Always clear when done
    store.clearAll();
}

Why this works:

  • Mutable storage: Buffer objects (not immutable strings) enable explicit clearing with fill(0) when credentials no longer needed
  • Timing-safe comparison: crypto.timingSafeEqual() avoids early-exit comparison for equal-length buffers; handle length mismatches without exposing useful secret-dependent timing
  • 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. Temporary JS strings/Buffers may remain until GC/native cleanup.
        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 Buffer immediately; finally block clears to prevent propagation (original req.body.password may exist briefly in Next.js)
  • BCrypt verification: compare() uses the salt and parameters encoded in the stored hash and avoids direct plaintext comparison
  • Temporary string limitation: passwordBuffer.toString('utf8') creates an immutable JavaScript string for the bcrypt API; this cannot be explicitly cleared, so prefer APIs that accept Buffer directly when available
  • Secure cookies: httpOnly prevents XSS access; Secure requires HTTPS; SameSite=Strict blocks CSRF; Path=/ available to all routes
  • JWT integration: createSecureSession() loads secret as Buffer from 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);
    let hash;
    let combined;

    try {
        // Use PBKDF2 for key derivation
        hash = await pbkdf2(passwordBuffer, salt, 600000, 32, 'sha256');

        // Combine salt + hash
        combined = Buffer.concat([salt, hash]);

        // Return as base64 for storage
        return combined.toString('base64');

    } finally {
        // Clear intermediate buffers after the storage string has been produced
        passwordBuffer.fill(0);
        salt.fill(0);
        if (hash) hash.fill(0);
        if (combined) combined.fill(0);
    }
}

async function verifyPassword(passwordString, storedHash) {
    const passwordBuffer = Buffer.from(passwordString, 'utf8');
    const combined = Buffer.from(storedHash, 'base64');
    let inputHash;

    try {
        // Extract salt and hash
        const salt = combined.slice(0, 16);
        const originalHash = combined.slice(16);

        // Hash input password with same salt
        inputHash = await pbkdf2(passwordBuffer, salt, 600000, 32, 'sha256');

        // Constant-time comparison
        return crypto.timingSafeEqual(originalHash, inputHash);

    } finally {
        // Clear buffers
        passwordBuffer.fill(0);
        combined.fill(0);
        if (inputHash) inputHash.fill(0);
    }
}

Why this works:

  • Computational cost: PBKDF2-HMAC-SHA256 with 600,000 iterations follows current OWASP guidance where PBKDF2 is required; tune parameters on production hardware and prefer Argon2id or bcrypt where appropriate
  • Memory clearing: Password converted to Buffer and cleared in finally to 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 Buffer objects in Map, then deletes from process.env to reduce attack surface (doesn't affect parent process, but removes from Node.js runtime)
  • Explicit clearing: fill(0) clears buffers on shutdown (exit, SIGINT handlers) 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; centralized SecureConfig class simplifies audit and maintenance

Remediation Steps

  1. Locate secrets stored in strings, object properties, closures, module-level variables, process environment snapshots, and in-memory maps.
  2. Trace framework input boundaries such as Express, Next.js, JSON parsers, and authentication middleware to identify unavoidable string copies.
  3. Use Buffer for secrets where downstream APIs accept it, allocate with Buffer.alloc() for new buffers, and clear with fill(0) in finally or shutdown paths.
  4. Avoid Buffer.allocUnsafe() for secret material and document any unavoidable Buffer.toString() conversions.
  5. Remove secrets from logs, error objects, telemetry, and serialized request/response data.
  6. Re-scan and review heap snapshots, crash reports, and process manager dump settings for the affected service.

Testing

  • Normal input: verify login, password hashing, token signing, and credential-cache flows still work with buffer-based handling.
  • Boundary input: test failed authentication, thrown errors, cancelled requests, and signal/shutdown handlers to confirm cleanup paths execute.
  • Malicious input: capture a controlled heap snapshot in a test environment and search for known test secrets, checking that avoidable long-lived copies are gone.

Additional Resources