Bcrypt Salt Rounds: How to Pick the Right Work Factor for Your Server
Quick answer
💡OWASP recommends a minimum bcrypt cost factor of 10, which takes approximately 100ms on a mid-range 2024 server. Use cost factor 12 if your hardware can absorb the 400ms latency on the login endpoint. Benchmark your specific server with bcrypt.hash('test', rounds) in a tight loop before choosing. If memory-hardness matters for your threat model, consider Argon2id as a drop-in alternative.
Error symptoms
- ✕
Login endpoint response time exceeds 2 seconds under moderate concurrent load - ✕
CPU usage spikes to 100% on a single core during login bursts - ✕
New user registrations are slow but password comparison during login is fast, indicating mismatched cost factors - ✕
Security audit flags bcrypt cost factor below 10 as non-compliant with OWASP ASVS v4.0 section 2.4.1 - ✕
Cost factor was set at application bootstrap five years ago and has never been revisited as hardware improved - ✕
Argon2id migration is blocked because the team cannot compare its parameters to bcrypt's single work factor
Common causes
- •Cost factor was copied from a Stack Overflow answer written in 2013 when factor 8 was considered acceptable
- •The developer benchmarked cost factor on a development laptop, not on the production server instance type
- •The application uses bcrypt.hashSync() in the request handler, blocking the event loop and compounding latency
- •Cost factor was lowered during a performance incident and never raised after hardware was upgraded
- •The team is unaware that OWASP updated the minimum recommendation from 8 to 10 in ASVS 4.0
When it happens
- •During a security audit or penetration test that includes OWASP ASVS compliance checks
- •After a database breach where the team needs to assess how long cracking the stored hashes would take
- •When scaling from a single server to a load-balanced cluster where login throughput becomes a design constraint
- •During a Node.js version upgrade that changes the native bcrypt binding performance profile
Examples and fixes
Run this script on your actual production server instance to find the highest cost factor that keeps hash time under your latency budget.
Benchmark cost factor on your specific hardware
❌ Wrong
// Guessing the cost factor based on documentation defaults
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10; // just copied from the README
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
// No benchmark, no measurement, no validation✅ Fixed
const bcrypt = require('bcrypt');
async function benchmarkCostFactor(targetMs = 200) {
for (let rounds = 8; rounds <= 16; rounds++) {
const start = Date.now();
await bcrypt.hash('benchmark-password-1234', rounds);
const elapsed = Date.now() - start;
console.log(`rounds=${rounds} time=${elapsed}ms`);
if (elapsed >= targetMs) {
console.log(`Use rounds=${rounds} (first to exceed ${targetMs}ms)`);
break;
}
}
}
benchmarkCostFactor(200);The wrong version silently uses an arbitrary value copied from documentation. The fixed version measures actual wall-clock time on the target hardware for each cost factor from 8 upward and stops at the first value that exceeds the latency budget. Run this on the exact EC2 instance type, Fly.io machine size, or Heroku dyno that will serve production traffic. A t3.micro will saturate at factor 11; a c6i.xlarge can comfortably run factor 13. The benchmark output gives you a data-driven number rather than a guess.
Detect the stored cost factor on each successful login and transparently re-hash with a higher factor if needed.
Lazy cost factor upgrade without forcing password resets
❌ Wrong
// Static cost factor, never updated
const ROUNDS = 10;
async function login(email, password) {
const user = await db.findUserByEmail(email);
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) throw new Error('Invalid credentials');
return generateSessionToken(user.id);
}✅ Fixed
const TARGET_ROUNDS = 12;
async function login(email, password) {
const user = await db.findUserByEmail(email);
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) throw new Error('Invalid credentials');
// Lazy upgrade: re-hash if stored rounds is below target
const storedRounds = bcrypt.getRounds(user.passwordHash);
if (storedRounds < TARGET_ROUNDS) {
const newHash = await bcrypt.hash(password, TARGET_ROUNDS);
await db.updatePasswordHash(user.id, newHash);
}
return generateSessionToken(user.id);
}The fixed version uses bcrypt.getRounds() to extract the cost factor embedded in the stored hash and compares it to the current target. If the stored hash is below the target, the plaintext is re-hashed at the higher cost factor and the record is updated. This transparently upgrades all active users over time without a forced password reset. Users who have not logged in recently keep their old cost factor indefinitely, which is acceptable because they never expose their password to the login path. The bcrypt.getRounds() function is available in both the bcrypt and bcryptjs npm packages.
How bcrypt work factor affects security and performance
Bcrypt is deliberately slow. It was designed in 1999 by Niels Provos and David Mazieres specifically to resist brute-force attacks by making each hash attempt computationally expensive. The cost factor (also called salt rounds or work factor) controls how many times the key derivation function iterates. The relationship is exponential: cost factor 11 takes twice as long as cost factor 10, and cost factor 12 takes four times as long as cost factor 10. This is by design: as hardware gets faster every year, you can increase the cost factor to maintain the same real-world resistance to cracking.
On a mid-range 2024 server (Intel Xeon, 3.5GHz, single thread), bcrypt at cost factor 10 takes approximately 80-120ms. At cost factor 12, the same operation takes 320-500ms. At cost factor 14, it takes 1.2-2 seconds. These are per-hash timings; each login request requires exactly one comparison, which takes the same time as hashing. The OWASP Authentication Cheat Sheet 2024 recommends a minimum of cost factor 10, noting that you should increase this to keep pace with hardware improvements over time.
The security implication is concrete. An attacker who obtains your database can attempt offline brute-force attacks limited only by their hardware budget. A modern GPU cluster can attempt approximately 10,000 bcrypt operations per second at cost factor 10. Given a typical English word plus symbol password (a 32-bit entropy password), that GPU cluster needs about 2.8 hours to crack a single hash exhaustively. At cost factor 12, the same attack takes 11 hours. At cost factor 14, 45 hours. Against a randomly generated 64-bit entropy password (e.g., 12 random alphanumeric characters), even cost factor 10 with the most powerful known GPU clusters would take centuries.
The key insight from OWASP is that bcrypt cost factor interacts with your password policy. If you enforce strong random passwords (12+ characters, truly random), cost factor 10 provides adequate protection even against well-resourced attackers. If your users choose low-entropy passwords (dictionary words, keyboard walks, names), a higher cost factor buys meaningful additional protection.
How to benchmark cost factor on production hardware
Never benchmark on a development laptop. The CPU architecture, clock speed, thermal throttling behavior, and available parallelism on your development machine may differ dramatically from the production environment. A 2023 MacBook Pro M2 Pro produces very different bcrypt timings than an AWS t3.medium or a Heroku Standard-1X dyno.
Run the benchmark script from the examples section on the exact instance type that will serve production traffic. Measure wall-clock time for a single hash at each cost factor from 8 to 16, and record the results. Then calculate your maximum sustainable throughput: if cost factor 12 takes 400ms and you want to handle 10 concurrent login attempts without queuing, you need at least 4 CPU cores dedicated to bcrypt. In Node.js, bcrypt's native binding runs in the libuv thread pool, so it does not block the event loop, but it does consume a thread pool worker for the duration of the operation. The default thread pool size is 4; adjust UV_THREADPOOL_SIZE if you expect login bursts larger than 4 concurrent requests.
For serverless deployments on AWS Lambda or Vercel Functions, the benchmark result depends on the memory allocation of the function, because Lambda allocates CPU proportional to memory. A 128MB Lambda function at cost factor 10 takes over 500ms; a 1024MB Lambda function at the same cost factor takes around 100ms. Benchmark at your specific memory allocation, not at the default.
After choosing a cost factor, add monitoring to your login endpoint that tracks the p50, p95, and p99 latency of the bcrypt.compare() call specifically (not the entire login handler). Set an alert if the p95 exceeds your latency budget. This gives you early warning if a hardware change or increased load pushes bcrypt time past acceptable limits.
Choosing and upgrading your cost factor safely
The OWASP ASVS 4.0 standard requires a minimum bcrypt cost factor of 10 for ASVS Level 1 compliance. For ASVS Level 2 and higher, the recommendation is to benchmark annually and increase the cost factor to maintain the same minimum wall-clock time (around 100ms) as hardware improves. In practice, this means incrementing the cost factor by 1 every 2-3 years as CPUs become faster.
For new applications, start at cost factor 12 if your login endpoint can tolerate 400-500ms. This provides 4x more resistance than the minimum requirement and will not need to be increased for several years. For existing applications running at cost factor 8 or 9, upgrade to 10 immediately and then plan for lazy migration to 12 over the next 6-12 months as all active users log in.
To perform a lazy cost factor upgrade, use bcrypt.getRounds(storedHash) after a successful compare() to check the cost factor of the stored hash. If it is below the target, re-hash the verified plaintext at the target cost factor and update the database record. This approach never requires a forced password reset and carries no risk of locking users out. The only users who will not be upgraded are those who never log in, which is an acceptable trade-off.
Security note: when increasing the cost factor, test the login endpoint under load on a staging environment that mirrors production before deploying. A cost factor increase from 10 to 12 quadruples the CPU time per login. If your login endpoint normally handles 100 requests per second, test whether your staging environment can sustain that traffic at the new cost factor. If not, add horizontal scaling before deploying the cost factor change.
Cost factor edge cases and gotchas
The cost factor is stored as part of the hash string, not as application configuration. A hash generated with cost factor 10 always stores $2b$10$ in its prefix. When you call bcrypt.compare(), the library reads the cost factor from the stored hash and uses it for comparison, ignoring any application-level cost factor setting. This means changing the SALT_ROUNDS constant in your application code does not affect the comparison of existing hashes. Only new hashes generated after the code change will use the new cost factor.
Bcrypt has a known maximum password length of 72 bytes. Passwords longer than 72 bytes are silently truncated. This edge case interacts with cost factor in an unexpected way: if you pre-hash the password with SHA-256 to work around the 72-byte limit, you must use consistent hex or base64 encoding for the pre-hash output, because the encoding changes the byte content. OWASP explicitly recommends this HMAC pre-hashing pattern for applications that need to support arbitrarily long passwords, but cautions that the pre-hash step must be consistent between registration and login.
On ARM-based servers (AWS Graviton, Apple Silicon), bcrypt timing can differ from x86 benchmarks by up to 40%. The native bcrypt npm package uses C code compiled for the target architecture, so Graviton3 instances are often 30-40% faster at bcrypt than equivalent Intel Xeon instances of the same price. Re-benchmark when changing instance families.
For containerized deployments with CPU limits set via Docker or Kubernetes cgroups, the available CPU time is throttled, which increases bcrypt wall-clock time compared to an un-throttled benchmark. A container with a 0.5 CPU limit will take approximately twice as long for bcrypt operations as the same container with no CPU limit. Always benchmark with the same CPU limit that will be active in production.
Common bcrypt salt round mistakes in production
The most common mistake is choosing cost factor 10 because it is the default in documentation and never revisiting it. OWASP notes that the appropriate cost factor changes as hardware improves. An application deployed in 2016 with cost factor 10 may now be under-protected relative to current GPU cracking capabilities. Review your cost factor every time you upgrade servers or change instance types.
A related mistake is confusing salt rounds with the number of salt bytes. The saltRounds parameter in bcrypt.hash(password, saltRounds) is the cost factor (log2 of the number of iterations), not the number of random bytes in the salt. Bcrypt always generates a 128-bit (16-byte) random salt regardless of the cost factor. You cannot configure the salt length. If you need a longer salt, use Argon2id, which supports configurable salt lengths.
Another frequent mistake is using bcrypt.hashSync() in a production web server to avoid callback complexity. In Node.js, the synchronous variant blocks the entire event loop thread for 100-500ms, preventing all other incoming requests from being processed during that time. If 5 users log in simultaneously on a single-threaded Node.js server using hashSync(), the last request in the queue waits up to 2.5 seconds before bcrypt even starts. Always use the async variant or worker threads for bcrypt operations in server code.
Finally, some developers set an extremely high cost factor (16+) thinking more is always better. At cost factor 16, a single bcrypt operation takes 8-16 seconds on typical hardware. This makes the login endpoint unusable for legitimate users and also makes it trivially easy for an attacker to perform a denial-of-service attack by flooding your login endpoint with requests, exhausting all CPU resources. The correct balance is the highest cost factor that keeps login response time acceptable for legitimate users under peak load.
Security and performance best practices for bcrypt
OWASP ASVS 4.0 section 2.4.1 states: use bcrypt with a work factor of at least 10 or equivalent. For new systems in 2026, target cost factor 12 as a starting point. This provides approximately 400ms per hash on a 2024-generation server and will not need to be increased for 3-5 years as hardware improves. Document your chosen cost factor and the benchmark results in your codebase, along with the date and instance type used for the benchmark.
Consider Argon2id as an alternative to bcrypt for new systems. Argon2id won the Password Hashing Competition in 2015 and is recommended by OWASP as the preferred choice when memory-hardness is required. Unlike bcrypt, Argon2id is memory-hard: attackers cannot trade time for space, which means GPU farms with high parallelism but limited memory per core cannot crack Argon2id as efficiently as bcrypt. The Node.js argon2 npm package provides a straightforward API. OWASP recommends Argon2id with a minimum of 19MB of memory, 2 iterations, and 1 degree of parallelism as of 2024.
For scrypt users, the Node.js built-in crypto.scrypt() function is available without any npm dependency. OWASP recommends scrypt with N=32768, r=8, p=1 as a minimum. However, scrypt is harder to configure correctly than bcrypt or Argon2id, and misconfiguration can produce a weak key derivation. Unless you have a specific reason to use scrypt, prefer bcrypt or Argon2id.
Regardless of algorithm, pair your password hashing with a pepper: a secret value stored in application configuration that is concatenated with the password before hashing. The pepper is never stored in the database, so an attacker who obtains a database dump cannot crack the hashes without also compromising the application server. Store the pepper in an environment variable or a secrets manager such as AWS Secrets Manager or HashiCorp Vault. Rotate the pepper annually by lazily re-hashing all active users on their next login.
Quick fix checklist
- ✓Benchmark bcrypt on the actual production instance type before setting the cost factor
- ✓Set cost factor to at least 10 per OWASP ASVS 4.0 section 2.4.1 minimum requirement
- ✓Use bcrypt.compare() (async) in all request handlers, never bcrypt.compareSync()
- ✓Extract the stored cost factor with bcrypt.getRounds() on each successful login and upgrade lazily
- ✓Document the cost factor choice, benchmark date, and instance type in the codebase
- ✓Review and re-benchmark the cost factor annually or whenever changing server instance types
- ✓Consider Argon2id for new systems where memory-hardness against GPU clusters is required
- ✓Pair bcrypt with a pepper stored in a secrets manager, never in the database
Related guides
Frequently asked questions
What bcrypt cost factor does OWASP recommend in 2026?
OWASP ASVS 4.0 section 2.4.1 recommends a minimum of cost factor 10 for bcrypt. For 2026 hardware, factor 12 is a better starting point, providing approximately 400ms per hash. OWASP also recommends Argon2id as the preferred algorithm for new systems, with 19MB memory, 2 iterations, and parallelism of 1 as a minimum configuration.
What is the difference between bcrypt salt rounds and iteration count?
Bcrypt salt rounds is the log2 cost factor. A value of 10 means 2^10 = 1024 internal iterations. A value of 12 means 2^12 = 4096 iterations. The naming is confusing because the parameter is sometimes called saltRounds but it controls the number of iterations, not the salt length. The bcrypt salt is always 16 random bytes regardless of the cost factor.
How do I check the cost factor of a stored bcrypt hash?
Use bcrypt.getRounds(hashString) from the bcrypt or bcryptjs npm package. It returns the cost factor as an integer. The cost factor is also readable directly: in the hash string $2b$12$..., the number between the second and third dollar signs is the cost factor (12 in this example).
Can I use bcrypt on AWS Lambda without hitting timeout limits?
Yes, but you must benchmark at your Lambda memory allocation. Lambda CPU is proportional to memory. A 128MB function at cost factor 10 can take over 500ms. A 1024MB function at the same cost factor takes around 100ms. Set your Lambda timeout to at least 3x the expected bcrypt time and benchmark at the exact memory allocation you will use in production.
Is Argon2id better than bcrypt for password hashing?
Argon2id provides memory-hardness in addition to time-hardness. GPU and ASIC attackers cannot parallelise Argon2id as effectively as bcrypt because each operation requires a configurable amount of RAM. OWASP recommends Argon2id for new systems. For existing bcrypt-based systems, upgrading to Argon2id requires a forced password reset or a dual-verification fallback strategy.
Does increasing cost factor break existing stored hashes?
No. The cost factor is embedded in the stored hash string and is read automatically by bcrypt.compare(). Changing the cost factor in your application code only affects new hashes. Existing hashes continue to verify correctly. To upgrade existing hashes to a higher cost factor, implement a lazy re-hash on the next successful login.
What UV_THREADPOOL_SIZE should I set for bcrypt in Node.js?
The default UV_THREADPOOL_SIZE is 4, meaning Node.js can run 4 concurrent bcrypt operations without queuing. If your login endpoint regularly receives more than 4 simultaneous requests, increase UV_THREADPOOL_SIZE to match your expected peak concurrency. Set it before the event loop starts, ideally as an environment variable: UV_THREADPOOL_SIZE=8 node server.js.
How often should I review and increase the bcrypt cost factor?
Review annually and whenever you upgrade server hardware. The goal is to maintain approximately 100-200ms per hash operation on production hardware. If a benchmark shows your current cost factor now completes in under 50ms, increase by 1. Track your benchmark results and the instance type in your codebase comments so future developers understand the reasoning.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.