How weak scrypt password hashing happens in Node.js and how to fix it
Storing passwords safely is one of those things that looks trivial in code but has outsized consequences when done wrong. In store-saas/server.mjs, a single function call to Node's built-in crypto.scryptSync() was quietly using weak defaults — and that's exactly the kind of subtle mistake that turns a routine data breach into a mass password-cracking event.
Introduction
The store-saas/server.mjs file handles user authentication for the application, and its hashPass() function is the gatekeeper responsible for turning plaintext passwords into secure, storable hashes:
function hashPass(password, salt) {
return crypto.scryptSync(String(password), salt, 32).toString("hex");
}
At first glance, this looks fine — it's using scrypt, a memory-hard KDF specifically designed to resist brute-force attacks, and it's passing a per-user salt. But there's a critical detail buried in what's not there: no cost parameters are specified. When you call crypto.scryptSync(password, salt, keylen) without a fourth options argument, Node.js silently falls back to its defaults: N=16384, r=8, p=1. Those defaults were reasonable years ago, but on modern hardware they no longer provide meaningful resistance against GPU-accelerated cracking.
The Vulnerability Explained
scrypt's security guarantee comes from its tunable cost parameters:
- N — the CPU/memory cost factor (higher = more memory and time per hash)
- r — block size (affects memory usage per iteration)
- p — parallelization factor
With the vulnerable code at line 75 of server.mjs:
crypto.scryptSync(String(password), salt, 32).toString("hex")
...Node.js uses N=16384. That means each hash computation only requires roughly 16 MB of memory and a relatively small number of CPU cycles. On a single consumer GPU, that cost factor allows an attacker to test hundreds of thousands to millions of password guesses per second once they have offline access to the hash database.
Attack scenario: Suppose an attacker gains access to the application's database — through a misconfigured backup, an exposed S3 bucket, or a SQL injection elsewhere in the stack. They extract rows containing password_hash and salt values produced by hashPass(). Because the hashes were generated with the weak default N=16384, the attacker loads the salted hashes into a GPU-based cracking tool (e.g., hashcat with a custom scrypt kernel) and runs a dictionary or mask attack. Given how cheap each guess is to compute, even moderately complex passwords fall within hours, and common/reused passwords fall almost immediately. From there, credential stuffing against other services using the same emails/passwords becomes trivial.
This is especially dangerous for a SaaS platform like store-saas, where compromised accounts could mean unauthorized access to customer data, payment configuration, or admin panels.
The Fix
The fix is a one-line change to hashPass() that explicitly sets stronger cost parameters instead of relying on Node's defaults:
Before:
function hashPass(password, salt) {
return crypto.scryptSync(String(password), salt, 32).toString("hex");
}
After:
function hashPass(password, salt) {
return crypto.scryptSync(String(password), salt, 32, { N: 131072, r: 8, p: 2 }).toString("hex");
}
Here's what changed and why it matters:
- N: 16384 → 131072 — an 8x increase in the memory/CPU cost factor. This directly multiplies the time and memory required for each hash attempt, which is the single biggest lever against GPU cracking since GPUs have limited per-core memory bandwidth compared to CPUs.
- p: 1 → 2 — doubling the parallelization factor further increases the computational work per hash, adding another dimension of cost that's hard for attackers to parallelize away cheaply.
- r: 8 — kept the same, as it already contributes meaningfully to memory usage per round.
The net effect: computing a single hash now takes measurably longer and consumes significantly more memory (well into the hundreds of megabytes territory), which is trivial for a legitimate login request (a fraction of a second) but devastating to an attacker trying to run millions of guesses in parallel on cracking rigs. This preserves normal login behavior — real users still authenticate instantly — while making offline brute-force attacks orders of magnitude more expensive.
Prevention & Best Practices
- Never call KDF functions like
scryptSync,pbkdf2, orbcrypt.hashwithout explicitly specifying cost parameters. Relying on library defaults means your security posture silently degrades as hardware gets faster and defaults become outdated. - Benchmark your cost parameters against your own infrastructure. Aim for a hashing time of roughly 250ms–1 second per password on your production hardware — fast enough for real users, slow enough to blunt brute-force at scale.
- Consider dedicated password-hashing libraries such as
argon2(winner of the Password Hashing Competition) which have memory-hardness properties specifically tuned to resist GPU/ASIC attacks, often with simpler safe defaults than rawscrypt. - Re-hash on login when parameters change. When you upgrade cost parameters (as done here), add logic to detect old-format hashes and transparently re-hash them the next time a user logs in successfully.
- Automate detection. Static analysis rules (Semgrep, CodeQL, or Orbis AppSec's scanners) can flag KDF calls that omit or under-specify cost parameters, catching this class of bug before it reaches production.
- Follow OWASP guidance. The OWASP Password Storage Cheat Sheet provides concrete, hardware-aware recommendations for scrypt, bcrypt, and Argon2 parameter tuning.
Key Takeaways
hashPass()instore-saas/server.mjs:75was callingcrypto.scryptSync()with implicit defaults (N=16384, r=8, p=1), not intentional weak settings — a classic "forgot to configure it" vulnerability.- The fix hardcodes
{ N: 131072, r: 8, p: 2 }, an 8x cost increase, directly in thescryptSynccall so future maintainers can't accidentally revert to defaults without noticing. - Password hashing cost parameters are not "set once and forget" — they must scale with hardware improvements, especially GPU/ASIC cracking capability.
- Salting alone (already present via the
saltparameter) is not sufficient; the cost factor is what actually slows down offline brute-force attacks. - Any KDF call missing explicit options in production authentication code should be treated as a high-severity finding.
How Orbis AppSec Detected This
- Source: User-supplied plaintext password entering
hashPass(password, salt)during registration/login flows instore-saas/server.mjs. - Sink:
crypto.scryptSync(String(password), salt, 32)atstore-saas/server.mjs:75, called without a cost-parameter object. - Missing control: No explicit, hardened scrypt cost parameters (
N,r,p) were configured, leaving the hash generation reliant on outdated library defaults. - CWE: CWE-916 — Use of Password Hash With Insufficient Computational Effort.
- Fix: Added explicit
{ N: 131072, r: 8, p: 2 }options to thescryptSynccall, increasing the computational cost of each password hash by roughly 8x.
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
A single missing argument in a crypto.scryptSync() call was all it took to leave store-saas's password storage vulnerable to efficient GPU-based cracking. The fix — explicitly setting N=131072, r=8, p=2 — is small in terms of lines changed but significant in terms of security impact, raising the cost of offline attacks by roughly an order of magnitude while leaving normal authentication flows untouched. The broader lesson is clear: when working with cryptographic primitives, defaults are not guarantees. Always specify and periodically re-tune your cost parameters, and treat any KDF call without explicit configuration as a red flag during code review.