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
•Technically reviewed by Anupam Mediratta•Published September 7, 2026•Reviewed 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.


Prevention and further reading

Frequently Asked Questions

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.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #93

Related Articles

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

high

undici 7.29.0 TLS Bypass: BalancedPool Drops Connect Options

A critical flaw in undici's BalancedPool implementation silently discards TLS certificate validation settings when routing requests through certain connection paths. Attackers on the network could intercept HTTPS traffic without triggering validation errors. The fix preserves connect options throughout the connection lifecycle.

critical

Model Fetching Without Integrity Verification in ONNX Loading

A machine learning application was fetching ONNX model files and chunks over the network without verifying their integrity, creating an opening for model poisoning attacks. The fix adds cryptographic integrity verification at the point where downloaded chunks are reassembled and cached, ensuring models have not been modified in transit or at rest.

high

nanoid 3.3.11 Integer Overflow: Predictable ID Generation

An integer overflow in nanoid 3.3.11's internal randomness generation causes the library to fall back to predictable ID sequences, undermining the cryptographic guarantees of its supposedly unguessable identifiers. The fix upgrades the dependency tree to patched versions 3.3.12 or 5.1.11.

high

generateUUID() Ditches MD5 for SHA-256 to Fix CWE-328

The `generateUUID()` helper built request-identifying UUIDs by hashing an input string with MD5, a cryptographically broken algorithm susceptible to collisions. The fix swaps the hash function for SHA-256, reducing the chance that two different inputs produce the same generated identifier.