Back to Blog
high SEVERITY6 min read

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.

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

Answer Summary

This is a weak password hashing vulnerability (CWE-916) in a Node.js application, caused by calling `crypto.scryptSync()` with default cost parameters (N=16384, r=8, p=1) instead of hardened settings. The fix explicitly sets `{ N: 131072, r: 8, p: 2 }`, increasing the memory and CPU cost of each hash computation by 8x, which slows GPU/ASIC-based cracking attacks against a leaked hash database to impractical speeds.

Vulnerability at a Glance

cweCWE-916 (Use of Password Hash With Insufficient Computational Effort)
fixExplicitly pass `{ N: 131072, r: 8, p: 2 }` to `scryptSync` to raise memory/CPU cost per hash
riskStolen password hashes can be cracked with GPU rigs at millions of guesses per second
languageJavaScript (Node.js)
root cause`hashPass()` called `crypto.scryptSync()` without a strong cost-parameter object, defaulting to N=16384, r=8, p=1
vulnerabilityWeak/Insufficient Password Hashing Parameters

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, or bcrypt.hash without 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 raw scrypt.
  • 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() in store-saas/server.mjs:75 was calling crypto.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 the scryptSync call 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 salt parameter) 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 in store-saas/server.mjs.
  • Sink: crypto.scryptSync(String(password), salt, 32) at store-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 the scryptSync call, 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.

References

Frequently Asked Questions

What is weak password hashing?

It's when a password hashing function uses insufficient work factors (like a low N/cost parameter in scrypt, bcrypt, or PBKDF2), allowing attackers to compute billions of hash guesses per second on GPUs or ASICs if the hash database is ever stolen.

How do you prevent weak password hashing in Node.js?

Use `crypto.scryptSync()` or `crypto.scrypt()` with explicitly tuned parameters (e.g., N ≥ 131072, r=8, p≥1) or switch to a dedicated library like `argon2` or `bcrypt`, and benchmark the cost so hashing takes roughly 250ms–1s on your server hardware.

What CWE is weak password hashing?

CWE-916 — Use of Password Hash With Insufficient Computational Effort.

Is increasing the scrypt cost parameter alone enough to prevent cracking?

It significantly raises the bar but isn't a silver bullet — you should also enforce unique per-user salts (already done here via `salt`), rate-limit login attempts, and consider Argon2id for even stronger memory-hardness against GPU/ASIC attacks.

Can static analysis detect weak password hashing?

Yes, static analysis and SAST tools (including OrbisAppSec's multi-agent scanner) can flag calls to `scryptSync`, `pbkdf2`, or `bcrypt` that omit or under-specify cost parameters, since the vulnerable pattern is a recognizable API misuse.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

critical

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.

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

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.