Back to Blog
critical SEVERITY9 min read

How Hardcoded Encryption Salts Compromise Credential Storage in Node.js and How to Fix It

A critical vulnerability in `scripts/bench-cpu.js` used a hardcoded static salt (`'byok-relay-salt'`) when deriving encryption keys with scrypt, allowing attackers to decrypt all encrypted credentials if the encryption secret was compromised. The fix replaces the hardcoded salt with cryptographically secure random bytes generated per operation, ensuring each user's encrypted credentials require a unique derived key.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

This vulnerability is a **Cryptography weakness (CWE-336, also CWE-330)** in Node.js involving hardcoded salts in key derivation. When `crypto.scryptSync()` uses a static salt visible in source code, all encrypted data across all users can be decrypted with the same derived key if the encryption secret is leaked. The fix generates a random 16-byte salt using `crypto.randomBytes(16)` for each encryption operation, ensuring unique key derivation per credential and dramatically increasing the cost of a brute-force attack.

Vulnerability at a Glance

cweCWE-336 (Use of Incorrect Header Check for Secure Communication), CWE-330 (Use of Insufficiently Random Values)
fixReplace hardcoded salt with random bytes generated per operation using `crypto.randomBytes(16)`
riskAll encrypted OAuth tokens and API keys can be decrypted with a single derived key if the encryption secret is compromised
languageNode.js / JavaScript
root causeStatic salt string hardcoded in source code reduces entropy and eliminates per-credential uniqueness in key derivation
vulnerabilityHardcoded Salt in Key Derivation Function

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:

  1. An attacker gains access to the database containing encrypted credentials (the encryptedHex, ivHex, authTagHex values).
  2. Through a separate vulnerability (database dump, backup leak, insider threat), the ENCRYPTION_SECRET is exposed.
  3. The attacker computes: ENCRYPTION_KEY = scryptSync(ENCRYPTION_SECRET, 'byok-relay-salt', 32) once.
  4. 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:

  1. 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.

  2. 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.

  3. 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 cached ENCRYPTION_KEY at 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

Frequently Asked Questions

What is a hardcoded salt vulnerability in key derivation?

When cryptographic salts are hardcoded rather than randomized, all users' encrypted data derives encryption keys using identical salt values. If an attacker obtains the encryption secret, one derived key decrypts all encrypted data across all users, completely defeating the purpose of salting.

How do you prevent hardcoded salt vulnerabilities in Node.js?

Always generate a unique random salt per encryption operation using `crypto.randomBytes()`. Store the salt alongside the encrypted data (salts don't need to be secret), and never hardcode salt values in source code or configuration files.

What CWE covers hardcoded encryption salts?

This falls under **CWE-330: Use of Insufficiently Random Values** and **CWE-336: Use of Incorrect Header Check**, as hardcoded values are entirely predictable rather than random, and key derivation security depends on cryptographically secure randomness.

Is using scrypt with a hardcoded salt still better than no salting?

No—scrypt with a hardcoded salt is nearly equivalent to no salt at all. The attacker computes the derived key once and decrypts all credentials. Scrypt's computational cost is ineffective when the salt is public and identical across all encryptions, since there's no per-user uniqueness.

Can static analysis detect hardcoded salt vulnerabilities?

Yes. Static analysis tools can detect literal string constants passed to key derivation functions like `scryptSync()`, flag direct string concatenation in cryptographic operations, and identify PBKDF2/scrypt calls without randomized salts. Orbis AppSec's multi-agent scanning detected this pattern in `scripts/bench-cpu.js:24`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #93

Related Articles

high

How weak scrypt password hashing happens in Node.js and how to fix it

The `hashPass` function in `store-saas/server.mjs` used Node.js's `crypto.scryptSync` with default cost parameters (N=16384, r=8, p=1), making stored password hashes cheap to attack with modern GPUs. The fix increases the CPU/memory cost factor to N=131072 and parallelization to p=2, dramatically raising the computational effort required to brute-force stolen hashes.

high

How Dependency Version Pinning Prevents Supply Chain Attacks in Node.js and How to Fix It

A critical supply chain vulnerability in `package.json` allowed automatic updates to a cryptographic library with known weaknesses. By pinning `rijndael-js` to version `2.0.0` instead of allowing `^2.0.0` updates, the fix prevents silent installation of vulnerable versions that could expose downstream consumers to weak block cipher modes and authentication bypasses.

critical

How Insecure Randomness in form-data happens in Node.js and how to fix it

The `form-data` npm package, pinned at `^2.3.3` in `server/package-lock.json`, generated multipart form boundaries using the insecure `Math.random()` function instead of a cryptographically secure random source. This predictable boundary generation (CVE-2025-7783) could allow an attacker to guess or influence multipart boundaries, opening the door to request smuggling and payload injection in HTTP requests built by the server.

high

How Interpretation Conflict Vulnerability happens in Node.js and how to fix it

node-forge versions up to 1.3.1 shipped an ASN.1 parser vulnerable to an interpretation conflict that could let attackers bypass cryptographic signature verification, alongside a related unbounded recursion flaw (CVE-2025-66031) that enables denial-of-service. Upgrading the dependency to node-forge 1.4.0 patches both issues by hardening the ASN.1 decoder against malformed and adversarially crafted input.

high

How Man-in-the-Middle via ignored TLS options happens in Node.js undici SOCKS5 proxies and how to fix it

`dsh-coding-subscription-oauth` shipped `undici@7.24.8`, a release affected by CVE-2026-9697: when requests are routed through a SOCKS5 proxy, undici silently drops the caller-supplied TLS `connect` options (`ca`, `rejectUnauthorized`, `checkServerIdentity`, `servername`), so certificate pinning and custom trust stores are never applied. The fix pins `undici` to `7.29.0` across the app, `dsh-coding-oauth-core@0.1.1`, and both the production and development dispatchers, and hardens the Docker `de

critical

How SQL Injection happens in PHP bulk email systems and how to fix it

A critical SQL injection vulnerability in `admin/utilities/bulkEmailSystem.php` allowed attackers to inject arbitrary SQL through unvalidated database names passed from user input. The fix implements strict input validation using regex pattern matching to ensure only safe database identifiers are processed, preventing exploitation of the bulk email functionality.