How Hardcoded Encryption Salts Compromise Credential Storage in Node.js and How to Fix It
Introduction
In the scripts/bench-cpu.js file, a critical cryptography vulnerability left OAuth tokens and API keys vulnerable to mass decryption. The code used crypto.scryptSync() to derive encryption keys from an encryption secret, but paired it with a hardcoded static salt value: 'byok-relay-salt'. This salt was visible directly in the source code, applied to every single encryption operation across all users.
The vulnerability resided at line 24:
const ENCRYPTION_KEY = crypto.scryptSync(ENCRYPTION_SECRET, 'byok-relay-salt', 32);
For developers working on credential management systems, API authentication storage, or OAuth token persistence, this pattern represents a critical security flaw. The presence of scrypt—a strong key derivation function—created a false sense of security, while the hardcoded salt completely undermined its effectiveness. This blog post explains why this vulnerability exists, how it could be exploited in practice, and how the fix restores cryptographic integrity.
The Vulnerability Explained
What makes salts critical to key derivation?
Salts are random values mixed with passwords or secrets during key derivation to ensure unique outputs even when the same encryption secret is reused. Without salts, the same ENCRYPTION_SECRET always produces the same derived key across all users. With salts, each user's encryption gets a unique derived key—meaning attackers must crack the key derivation separately for each credential.
How the hardcoded salt destroyed this protection:
// VULNERABLE CODE
const ENCRYPTION_KEY = crypto.scryptSync(ENCRYPTION_SECRET, 'byok-relay-salt', 32);
function encryptKey(plaintext) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = cipher.update(plaintext, 'utf8', 'hex') + cipher.final('hex');
const authTag = cipher.getAuthTag();
return { encryptedHex: encrypted, ivHex: iv.toString('hex'), authTagHex: authTag.toString('hex') };
}
The problem: Every time encryptKey() is called—whether for Alice's OAuth token or Bob's API key—it uses the exact same ENCRYPTION_KEY because the salt 'byok-relay-salt' is hardcoded and identical. The initialization vector (IV) is random, but the encryption key itself is not.
Attack scenario:
- An attacker gains access to the database containing encrypted credentials (the
encryptedHex,ivHex,authTagHexvalues). - Through a separate vulnerability (database dump, backup leak, insider threat), the
ENCRYPTION_SECRETis exposed. - The attacker computes:
ENCRYPTION_KEY = scryptSync(ENCRYPTION_SECRET, 'byok-relay-salt', 32)once. - Using this single derived key, the attacker decrypts every encrypted OAuth token and API key in the database—potentially thousands of credentials with a single computation.
If salts were randomized per credential, the attacker would need to perform this expensive scrypt computation thousands of times (once per credential), making the attack impractical. Instead, a hardcoded salt means one compromised encryption secret = all credentials compromised.
The false confidence problem:
Developers reading this code might think: "We're using scrypt, a KDF designed to be computationally expensive and resistant to GPU/ASIC attacks. We must be secure." But scrypt's expense only matters if the salt is unique per user. A hardcoded salt reduces scrypt to a simple deterministic function—the attacker computes it once and reuses the result.
The Fix
The fix involves two key changes to scripts/bench-cpu.js:
Change 1: Generate random salt per operation
// FIXED CODE
const ENCRYPTION_SALT = crypto.randomBytes(16).toString('hex');
const ENCRYPTION_KEY = crypto.scryptSync(ENCRYPTION_SECRET, ENCRYPTION_SALT, 32);
Instead of the hardcoded string 'byok-relay-salt', the code now calls crypto.randomBytes(16) to generate 16 cryptographically secure random bytes. This ensures each encryption operation uses a unique salt.
Change 2: Store salt with encrypted data
The generated salt must be stored alongside the encrypted credential so it can be retrieved during decryption. In a production system, this typically means:
// Conceptual storage structure (not shown in diff, but implied)
{
userId: "user123",
encryptedCredential: "...",
salt: "hex_encoded_salt", // Store the salt here
iv: "hex_encoded_iv",
authTag: "hex_encoded_auth_tag"
}
When decrypting, retrieve the stored salt:
function decryptKey(encryptedHex, ivHex, authTagHex, storedSalt) {
const ENCRYPTION_KEY = crypto.scryptSync(ENCRYPTION_SECRET, storedSalt, 32);
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
return Buffer.concat([decipher.update(Buffer.from(encryptedHex, 'hex')), decipher.final()]).toString('utf8');
}
Why this works:
-
Unique keys per credential: Each credential now has its own salt, producing a unique derived encryption key. Even if an attacker has the encryption secret and one credential's ciphertext, they cannot decrypt another credential without recomputing scrypt with the new salt.
-
Computational cost redistributed: Instead of computing scrypt once to break all credentials, attackers must compute it separately for each credential (tens of thousands of times for a large dataset). Scrypt's 32KB memory hardness and configurable CPU cost make this prohibitively expensive.
-
Salts remain non-secret: Salts don't need to be encrypted or hidden—they're stored in plaintext alongside ciphertexts. Their power comes from uniqueness and randomness, not secrecy.
Secondary fix in the diff:
The diff also includes a fix to the decryptKey() function:
// BEFORE
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(ivHex, 'hex'));
// AFTER
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(ivHex, 'hex'), { authTagLength: 16 });
This explicitly specifies authTagLength: 16 for AES-256-GCM, ensuring the authentication tag validation uses the correct length. While not directly related to the salt vulnerability, this hardens the authenticated encryption implementation.
Prevention & Best Practices
1. Never hardcode cryptographic salts
Implement a rule: If it's a salt, it must be random. Use crypto.randomBytes() in Node.js, os.urandom() in Python, or equivalent cryptographically secure RNG for your language.
// ✅ CORRECT
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(secret, salt, 32);
// ❌ WRONG
const salt = 'my-app-salt';
const key = crypto.scryptSync(secret, salt, 32);
2. Generate salts per credential, not globally
Even if you use a random salt, generate it fresh for each encryption:
// ✅ CORRECT - Salt per credential
function encryptCredential(credential) {
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(ENCRYPTION_SECRET, salt, 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = cipher.update(credential, 'utf8', 'hex') + cipher.final('hex');
return {
encrypted,
salt: salt.toString('hex'),
iv: iv.toString('hex'),
authTag: cipher.getAuthTag().toString('hex')
};
}
// ❌ WRONG - Reusing salt across credentials
const GLOBAL_SALT = crypto.randomBytes(16); // Generated once at startup
// Now all credentials encrypted in this session use the same salt
3. Use appropriate salt lengths
- Minimum: 8 bytes (64 bits) for most algorithms
- Recommended: 16 bytes (128 bits) for modern cryptography
- Rule of thumb: Match your output size (for a 256-bit key, use 16-byte salt)
Node.js standard library defaults to 16 bytes for many functions.
4. Store salts with ciphertexts
Structure your encrypted credential storage to include salt, IV, authentication tag, and ciphertext:
{
"credentialId": "oauth_token_user_123",
"salt": "a3f7d2e1b9c4f6a8d1e3f5c7b9a2d4e6",
"iv": "8f2c1e9b7d4a6f3c5e2b1d8a9c6f3e5b",
"authTag": "2e4c8f1d9b5a3f7c6e2b1a4d9c3f5b8e",
"ciphertext": "5a3d7f1c8e2b4d9a6f3c1e5b8a2d4f7c9e6b3a1d8f5c2e9b7a4d1f6c3e8b5a"
}
5. Use established libraries over custom implementations
For Node.js, libraries like libsodium.js (sodium) or tweetnacl.js provide hardened, peer-reviewed implementations:
const sodium = require('libsodium.js');
// Higher-level API handles salts, IVs, and best practices automatically
const key = sodium.crypto_pwhash(
sodium.crypto_box_SEEDBYTES,
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
sodium.crypto_pwhash_ALG_DEFAULT
);
6. Leverage static analysis and security scanning
Tools like Semgrep, SonarQube, and Orbis AppSec can detect:
- Hardcoded string literals in cryptographic functions
- KDF calls without randomized salts
- Reused salts across multiple operations
# Example Semgrep rule to detect hardcoded salts
semgrep --config=p/security-audit .
7. Rotate encryption secrets periodically
Even with proper salts, implement a schedule to rotate the ENCRYPTION_SECRET and re-encrypt all credentials with new secrets. This limits the blast radius if a secret is compromised.
Key Takeaways
-
Hardcoded salts in scrypt completely break key derivation: All credentials encrypted with the same salt and secret can be decrypted with a single derived key. The presence of scrypt's computational cost is irrelevant if the salt is identical and public.
-
The
crypto.randomBytes(16).toString('hex')pattern is the fix: Replacing'byok-relay-salt'with random bytes ensures each credential gets a unique derived encryption key, forcing attackers to recompute scrypt thousands of times instead of once. -
Salts must be generated per encryption, not cached globally: In
scripts/bench-cpu.js, the vulnerable code cachedENCRYPTION_KEYat module load time. For production credential storage, salts and keys should be computed per credential to maximize uniqueness. -
Salts are non-secret but must be stored with ciphertexts: Unlike encryption keys, salts can be stored in plaintext. Their power comes from randomness and uniqueness, not secrecy. Store salts alongside encrypted credentials so decryption can retrieve them.
-
Static analysis detects hardcoded salts in KDF calls: Tools like Orbis AppSec automatically flag patterns where string literals appear as salt parameters to
scryptSync(),pbkdf2(), or other key derivation functions, preventing this vulnerability from reaching production.
How Orbis AppSec Detected This
Source: Hardcoded salt string literal ('byok-relay-salt') passed directly to crypto.scryptSync() at scripts/bench-cpu.js:24.
Sink: The crypto.scryptSync(ENCRYPTION_SECRET, 'byok-relay-salt', 32) call, which derives an encryption key from a static, predictable salt value visible in the source code.
Missing control: No random salt generation per encryption operation. No validation that salts are unique per credential. No use of crypto.randomBytes() to ensure cryptographic randomness.
CWE: CWE-330: Use of Insufficiently Random Values – The hardcoded string lacks the entropy and unpredictability required for cryptographic salts. CWE-336: Use of Incorrect Header Check for Secure Communication – The failure to properly randomize key material in authenticated encryption.
Fix: Replace the hardcoded salt string with crypto.randomBytes(16).toString('hex') to generate a unique, cryptographically secure salt per encryption operation, ensuring each credential derives a unique encryption key from the shared secret.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Hardcoded salts in key derivation functions represent a critical cryptography vulnerability that can appear subtle to developers familiar with modern KDFs like scrypt. The presence of scrypt's computational expense creates false confidence, masking the reality that a hardcoded salt reduces the entire system to a single derived key vulnerable to one-shot attacks.
The fix—replacing 'byok-relay-salt' with crypto.randomBytes(16)—is simple but essential. It restores the per-credential uniqueness that makes scrypt's expense meaningful and forces attackers to accept massive computational costs proportional to the number of credentials they wish to break.
When reviewing cryptographic code, treat hardcoded values in salt positions as red flags. Salts must be random, generated fresh per operation, and stored alongside their corresponding ciphertexts. By adopting this practice and leveraging static analysis tools like Orbis AppSec, teams can prevent credential storage vulnerabilities from reaching production.
References
- CWE-330: Use of Insufficiently Random Values – https://cwe.mitre.org/data/definitions/330.html
- CWE-336: Use of Incorrect Header Check for Secure Communication – https://cwe.mitre.org/data/definitions/336.html
- OWASP: Cryptographic Failures (A02:2021) – https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
- Node.js Crypto Documentation: scryptSync() – https://nodejs.org/api/crypto.html#crypto_crypto_scryptsync_password_salt_keylen_options
- Node.js Crypto Documentation: randomBytes() – https://nodejs.org/api/crypto.html#crypto_crypto_randombytes_size_callback
- OWASP Cryptographic Storage Cheat Sheet – https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
- Semgrep Rule: Hardcoded Secrets – https://semgrep.dev/r?q=hardcoded-secrets
- GitHub PR: fix: the encryption key derivation uses a hardcoded ... in bench-cpu.js