bcrypt remains the most widely deployed password hashing function in the Node.js ecosystem, and in 2026 it still earns its place. The OWASP Password Storage Cheat Sheet lists Argon2id as the first choice, yet it explicitly accepts bcrypt with a work factor of 10 or more and a 72-byte password limit. Millions of production systems run bcrypt today, the bcrypt npm package shipped version 6.0.0, and the pure-JavaScript bcryptjs reached 3.0.3. If you store user passwords, you need to get this right once and never touch it again.

This tutorial builds a complete, working authentication backend in Node.js using bcrypt for password hashing. You will install the library, hash and verify passwords, benchmark and pick a safe cost factor, handle bcrypt’s notorious 72-byte limit, wire register and login endpoints in Express, rehash credentials automatically when you raise the work factor, and migrate legacy hashes. Every step includes runnable code and real output. Budget about 45 minutes. By the end you will have a project you can drop into any service that authenticates users.

If you have already read our Argon2 password hashing tutorial, treat this as the bcrypt companion. The two share the same threat model. They differ in defaults, tuning, and the traps that bite implementers.

Why bcrypt Still Matters for Password Hashing in 2026

Password hashing exists to make stolen databases worthless. When attackers exfiltrate your users table, they should find values that cost years of compute to crack, not reversible secrets. A general-purpose hash like SHA-256 fails this job because it runs billions of times per second on a GPU. bcrypt fails it deliberately. It is slow by design, and you tune exactly how slow.

bcrypt dates to 1999, built on the Blowfish cipher by Niels Provos and David Mazières. That age is a feature. More than two decades of cryptanalysis have found no practical break. The algorithm carries a built-in salt, a tunable cost factor, and a self-describing output format that stores everything you need to verify a password later. You do not manage salts in a separate column. The hash string carries its own.

The honest 2026 picture: OWASP now prefers Argon2id because it is memory-hard, which blunts GPU and ASIC attacks more effectively than bcrypt’s CPU-bound design. scrypt, available natively through Node’s crypto.scrypt(), is also memory-hard and ships in the standard library. So why choose bcrypt? Three reasons. It is battle-tested and boring, which is exactly what you want in security code. It has zero native-build friction with bcryptjs, which matters on serverless and Alpine containers. And it interoperates with decades of existing systems, so migrations rarely fight you. For a deeper grounding in how one-way functions protect data, see our explainer on cryptographic hash functions.

The decision rule is simple. New greenfield service with no constraints? Reach for Argon2id. Existing Node app, broad platform support, or a team that values a library with no surprises? bcrypt is a defensible, OWASP-sanctioned choice. The rest of this tutorial assumes you picked bcrypt and want to deploy it without the common mistakes.

How bcrypt Works Under the Hood

Understanding the internals helps you avoid the pitfalls later. bcrypt runs a modified Blowfish key schedule called Eksblowfish (Expensive Key Schedule Blowfish). Where standard Blowfish sets up its key once, Eksblowfish repeats an expensive setup phase 2 to the power of the cost factor times. That single exponent is your security dial.

Each increase of the cost factor by 1 doubles the work. A cost of 10 runs 1,024 key-setup rounds. A cost of 12 runs 4,096. A cost of 14 runs 16,384. This is why you cannot pick a giant number and walk away: every login pays the cost, and a value too high turns your login endpoint into a denial-of-service vector against yourself.

bcrypt generates a 128-bit (16-byte) random salt for every password and folds it into the key schedule. Identical passwords therefore produce different hashes, which defeats rainbow tables and lets you spot nothing about password reuse from the stored values. The output is a single self-contained string in Modular Crypt Format. Here is how to read it:

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
 |  |  |                     |
 |  |  |                     +-- 31-char base64 hash (184 bits)
 |  |  +------------------------ 22-char base64 salt (128 bits)
 |  +--------------------------- cost factor (12 = 2^12 rounds)
 +------------------------------ algorithm version identifier ($2b$)

The $2b$ prefix is the modern version marker. You may still see $2a$ in older databases and $2y$ from PHP. The bcrypt npm library writes $2b$ and verifies all three, so legacy hashes keep working. The full string is always 60 characters, which is why a VARCHAR(60) or CHAR(60) column fits exactly, though a slightly larger column costs nothing and protects you against format changes. For the wider context of how one-way digests underpin signatures and integrity, our SHA-256 explainer covers the building blocks.

Prerequisites and Package Versions

Pin your versions. Security tutorials that wave at “the latest” age badly, so here is exactly what this project uses and was tested against in June 2026.

ComponentVersionNotes
Node.js20.20.2 LTS (or 22.x)Any active LTS works; native crypto APIs are stable
npm10.xShips with Node 20/22
bcrypt6.0.0Native C++ addon, fastest option
bcryptjs3.0.3Pure JS, zero build deps, drop-in fallback
express4.21.xFor the demo API
express-rate-limit7.xLogin throttling

You need a terminal, a code editor, and basic JavaScript familiarity with async/await. No database is required; the project uses an in-memory store so you can focus on hashing, but the patterns map directly to PostgreSQL, MySQL, or MongoDB. One decision up front: the native bcrypt package compiles a C++ addon and is faster, while bcryptjs is pure JavaScript with no build step and runs anywhere, including serverless functions and minimal Alpine images. Their APIs are nearly identical. This tutorial uses native bcrypt and flags every line you would change for bcryptjs.

Step 1: Initialize the Project and Install bcrypt

Create a fresh directory and initialize it. The "type": "module" flag lets you use modern import syntax throughout.

mkdir bcrypt-auth && cd bcrypt-auth
npm init -y
npm pkg set type=module
npm install [email protected] express@4 express-rate-limit@7

If the native build fails (no compiler, Alpine, or some serverless runtimes), swap one line and change your imports from bcrypt to bcryptjs:

npm install [email protected]   # pure-JS alternative, no compiler needed

Verify the install resolved the versions you expect before writing any code:

$ npm ls bcrypt
[email protected] /home/you/bcrypt-auth
└── [email protected]

This pinned tree is what you should commit. Lock files matter for security packages: an unexpected major bump can change defaults. Commit your package-lock.json and review it on every update.

Step 2: Hash Your First Password

Create hash-demo.js. The high-level API does everything in one call: it generates a salt, runs the key schedule, and returns the full 60-character string.

// hash-demo.js
import bcrypt from 'bcrypt';

const COST = 12;                // work factor, 2^12 = 4096 rounds
const password = 'correct horse battery staple';

const hash = await bcrypt.hash(password, COST);
console.log('hash:', hash);
console.log('length:', hash.length);
console.log('cost embedded:', hash.split('$')[2]);

Run it with node hash-demo.js. Your salt is random, so your hash differs from this one, but the structure matches exactly:

hash: $2b$12$kQ8pXcZ3mFv1u9oRwYb0xeQ7sN2tLgH6jKdC4aBzVfE8iWmPrSuG
length: 60
cost embedded: 12

Two details to internalize now. First, never log real password hashes in production; this is a demo. Second, bcrypt.hash takes either a number (a cost factor, and the library generates the salt for you) or a pre-generated salt string as the second argument. Passing the cost number is the right call in nearly every case. The next step shows how to pick that number.

Step 3: Benchmark and Choose the Right Cost Factor

The cost factor is the only knob that matters for bcrypt’s strength, and OWASP’s guidance is explicit: use a work factor of 10 or more, then benchmark on your own hardware. The target most teams aim for is roughly 250 milliseconds per hash. That is slow enough to punish attackers and fast enough that users never notice login latency. Write a benchmark instead of guessing.

// benchmark.js
import bcrypt from 'bcrypt';

const password = 'a-representative-test-password-123';

for (const cost of [10, 11, 12, 13, 14]) {
  const start = process.hrtime.bigint();
  await bcrypt.hash(password, cost);
  const ms = Number(process.hrtime.bigint() - start) / 1e6;
  console.log(`cost ${cost}: ${ms.toFixed(1)} ms`);
}

Run node benchmark.js on the machine that will actually serve logins, not your laptop. The numbers below are illustrative results from a single 2025-era cloud vCPU. Yours will differ, which is the entire point of measuring.

Cost factorRounds (2^cost)Illustrative timeVerdict
101,024~65 msOWASP minimum, acceptable
112,048~130 msGood balance
124,096~260 msRecommended default for 2026
138,192~520 msHigh security, slower UX
1416,384~1040 msUsually too slow for interactive login

Pick the highest cost that keeps you near or under 250 ms. For most 2026 deployments that lands at 12. Store this value in one place, an environment variable or a config constant, so you can raise it later without hunting through the codebase. Step 8 shows how to migrate existing hashes when you bump it.

Step 4: Verify Passwords with bcrypt.compare

Verification never decrypts anything. bcrypt extracts the cost and salt from the stored hash, re-runs the key schedule against the candidate password, and compares the result in constant time. You hand it the plaintext attempt and the stored hash; it returns a boolean.

// verify-demo.js
import bcrypt from 'bcrypt';

const stored = await bcrypt.hash('hunter2', 12);

const good = await bcrypt.compare('hunter2', stored);
const bad  = await bcrypt.compare('wrong-password', stored);

console.log('correct password:', good);   // true
console.log('wrong password:', bad);       // false

The critical rule: always use bcrypt.compare, never ===. Re-hashing the candidate and string-comparing it yourself fails, because the salt embedded in the stored hash is what makes the comparison valid, and a manual === on two independently generated hashes will always be false. bcrypt.compare also runs in constant time relative to the hash, which closes a class of timing side channels. Treat compare as the only verification path in your code.

Step 5: Handle the 72-Byte Limit Safely

This is the single most misunderstood bcrypt behavior, and it has real security impact. bcrypt only reads the first 72 bytes of the input. Everything past byte 72 is silently ignored. That is bytes, not characters. An emoji or a CJK character can occupy 3 to 4 bytes in UTF-8, so a passphrase that looks like 30 characters might consume 90 bytes, and bcrypt quietly drops the tail.

The danger is twofold. Long, strong passphrases get truncated, weakening protection users believe they have. Worse, two different long passwords that share their first 72 bytes will validate against each other. First, measure the problem:

// limit-demo.js
import bcrypt from 'bcrypt';

const base = 'x'.repeat(72);
const hash = await bcrypt.hash(base, 12);

// identical first 72 bytes, different tail -> both true
console.log(await bcrypt.compare(base + 'AAAA', hash)); // true (!!)
console.log(await bcrypt.compare(base + 'ZZZZ', hash)); // true (!!)

The robust fix is to pre-hash the password with SHA-256 before handing it to bcrypt, which compresses any length input to a fixed 32 bytes. Encode the digest as base64 so it never contains a null byte, because a NUL inside the input can truncate it early in some bcrypt versions. This pattern is the one the OWASP cheat sheet describes for bcrypt and long inputs.

// password-utils.js
import bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';

const COST = 12;

// SHA-256 -> base64 keeps every input under 45 bytes and NUL-free
function prepare(password) {
  return createHash('sha256').update(password, 'utf8').digest('base64');
}

export async function hashPassword(password) {
  return bcrypt.hash(prepare(password), COST);
}

export async function verifyPassword(password, hash) {
  return bcrypt.compare(prepare(password), hash);
}

Decide this policy once, at launch, and apply it to both hashing and verifying. If you add pre-hashing later, existing hashes break, because the input changed. That is a migration, covered in Step 9. The rest of this project uses hashPassword and verifyPassword from this module so every code path handles long inputs identically.

Step 6: Build the Register Endpoint with Express

Now assemble the API. Create server.js with an in-memory user store and a registration route that hashes the password before storing anything. The plaintext never leaves the request handler.

// server.js
import express from 'express';
import { hashPassword, verifyPassword } from './password-utils.js';

const app = express();
app.use(express.json());

const users = new Map(); // email -> { email, passwordHash }

app.post('/register', async (req, res) => {
  const { email, password } = req.body ?? {};
  if (!email || !password) {
    return res.status(400).json({ error: 'email and password required' });
  }
  if (password.length < 8) {
    return res.status(400).json({ error: 'password too short' });
  }
  if (users.has(email)) {
    return res.status(409).json({ error: 'user already exists' });
  }
  const passwordHash = await hashPassword(password);
  users.set(email, { email, passwordHash });
  return res.status(201).json({ email });
});

Notice what is missing on purpose: the response never echoes the password or the hash. Enforce a minimum length (8 is a floor, not a goal) and reject empty bodies. In a real database you would store passwordHash in a CHAR(60) or larger column and rely on a unique index on email instead of the in-memory has() check. Pair this with multi-factor authentication for accounts that warrant it; our two-factor authentication tutorial slots in cleanly on top of this flow.

Step 7: Build the Login Endpoint

The login route verifies the submitted password against the stored hash. The subtle part is the error response. Return the same message and status whether the email is unknown or the password is wrong, so attackers cannot enumerate which accounts exist.

app.post('/login', async (req, res) => {
  const { email, password } = req.body ?? {};
  if (!email || !password) {
    return res.status(400).json({ error: 'email and password required' });
  }

  const user = users.get(email);
  // Verify against a dummy hash even when the user is missing, to equalize timing
  const hashToCheck = user?.passwordHash
    ?? '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva';

  const ok = await verifyPassword(password, hashToCheck);

  if (!user || !ok) {
    return res.status(401).json({ error: 'invalid credentials' });
  }
  return res.json({ email: user.email, token: 'issue-a-real-jwt-here' });
});

app.listen(3000, () => console.log('listening on http://localhost:3000'));

The dummy-hash trick matters. Without it, a missing user returns instantly while a real user pays bcrypt’s full cost, and that timing gap leaks which emails are registered. Running verifyPassword against a fixed invalid hash even on the miss path keeps both branches roughly equal. Once login succeeds, issue a real session token; our JWT authentication walkthrough covers signing and verifying that token properly.

Step 8: Rehash Passwords When You Raise the Cost Factor

Hardware gets faster, so the cost factor you pick in 2026 will be too weak by 2029. The good news: because every hash stores its own cost, you can upgrade users transparently at their next login. Read the embedded cost, and if it is below your current target, rehash the password you already have in hand.

// add to password-utils.js
export function needsRehash(hash, targetCost = COST) {
  // hash format: $2b$<cost>$...
  const parts = hash.split('$');
  const currentCost = parseInt(parts[2], 10);
  return currentCost < targetCost;
}

Wire it into the login route, right after a successful verification, while you still have the plaintext:

// inside /login, after `ok` is confirmed true
if (user && ok && needsRehash(user.passwordHash)) {
  user.passwordHash = await hashPassword(password);
  // persist the new hash to your database here
}

This is the elegant part of bcrypt’s self-describing format. You never force a password reset. You raise the global cost, deploy, and the fleet upgrades organically as users sign in. Inactive accounts keep their old hashes, which is acceptable because nobody is logging into them. The native bcrypt package also exposes bcrypt.getRounds(hash) if you prefer a library helper over manual parsing.

Step 9: Migrate Legacy Hashes Without a Password Reset

Inheriting a system that stored unsalted SHA-1 or MD5? You cannot un-hash those, but you can wrap them. Hash the existing digests with bcrypt at rest, then peel off the wrapper at login. This upgrades your whole table immediately, before a single user logs in.

// migrate.js  (run once over your existing table)
import bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';

// existing column held: sha1(password) in hex
async function wrapLegacy(sha1Hex) {
  // bcrypt over the legacy digest, marked so login knows the scheme
  const wrapped = await bcrypt.hash(sha1Hex, 12);
  return 'legacy-sha1:' + wrapped;
}

// at login, detect the marker and verify in two stages
export async function verifyMigrated(password, stored) {
  if (stored.startsWith('legacy-sha1:')) {
    const sha1Hex = createHash('sha1').update(password).digest('hex');
    return bcrypt.compare(sha1Hex, stored.slice('legacy-sha1:'.length));
  }
  return bcrypt.compare(password, stored);
}

After a migrated user authenticates, immediately rehash their real password with plain bcrypt and drop the legacy-sha1: marker, exactly like Step 8. Over weeks your active users transition to clean bcrypt hashes, and the weak legacy digests vanish from the live path. This zero-friction approach beats forcing a mass password reset, which spikes support tickets and trains users to expect reset emails (a phishing risk). For the broader hygiene picture, our guide to what actually keeps accounts safe is worth a read.

Step 10: Add Rate Limiting and Defense in Depth

bcrypt protects the database after a breach. It does nothing to stop online brute force against your live login endpoint. A determined attacker can still try thousands of passwords per account if you let them. Rate limiting closes that gap.

// add near the top of server.js
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  limit: 10,                   // 10 attempts per window per IP
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  message: { error: 'too many attempts, try again later' },
});

// apply it to the login route only
app.post('/login', loginLimiter, async (req, res) => { /* ... */ });

Layer additional defenses for production: a per-account lockout or exponential backoff after repeated failures, CAPTCHA after a threshold, and breach-password screening against the Have I Been Pwned k-anonymity API so users cannot pick a password already in a public dump. None of these replace bcrypt; they complement it. Password hashing is your last line of defense, not your only one.

Step 11: Run and Test the Complete Project

Start the server with node server.js and exercise it with curl. First register a user, then log in with the right and wrong passwords.

$ curl -s -X POST localhost:3000/register \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"s3cur3-passphrase!"}'
{"email":"[email protected]"}

$ curl -s -X POST localhost:3000/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"s3cur3-passphrase!"}'
{"email":"[email protected]","token":"issue-a-real-jwt-here"}

$ curl -s -X POST localhost:3000/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"wrong"}'
{"error":"invalid credentials"}

Add a quick automated test so regressions surface early. Node’s built-in test runner needs no extra dependencies.

// password-utils.test.js  -> run: node --test
import { test } from 'node:test';
import assert from 'node:assert';
import { hashPassword, verifyPassword, needsRehash } from './password-utils.js';

test('round trips a password', async () => {
  const h = await hashPassword('correct horse battery staple');
  assert.equal(await verifyPassword('correct horse battery staple', h), true);
  assert.equal(await verifyPassword('wrong', h), false);
});

test('long passwords are not truncated', async () => {
  const a = 'x'.repeat(100) + 'AAAA';
  const b = 'x'.repeat(100) + 'ZZZZ';
  const h = await hashPassword(a);
  assert.equal(await verifyPassword(b, h), false); // pre-hash saves us
});

test('flags weak cost for rehash', () => {
  assert.equal(needsRehash('$2b$10$' + 'a'.repeat(53)), true);
});

Run node --test and confirm all three pass. The middle test is the payoff for Step 5: without pre-hashing, two 104-byte passwords sharing their first 72 bytes would both validate, and the assertion would fail.

bcrypt vs Argon2id vs scrypt: Picking the Right Algorithm

You should know exactly where bcrypt sits among modern password hashing options so you can defend the choice in a security review. All three are slow by design; they differ in what kind of slow.

PropertybcryptArgon2idscrypt
Year introduced199920152009
Memory-hardNo (CPU-bound)Yes (tunable)Yes (tunable)
GPU/ASIC resistanceModerateStrongStrong
Node availabilitynpm (bcrypt/bcryptjs)npm (argon2)Built-in crypto.scrypt
Input length cap72 bytesNoneNone
OWASP 2025 stanceAcceptablePreferredAcceptable
Native build neededYes (bcrypt) / No (bcryptjs)YesNo

The practical takeaway: Argon2id wins on raw resistance because attackers must pay for memory, not just compute, and memory is expensive to parallelize. bcrypt’s weakness is that it fits in a tiny memory footprint, so a GPU can run many instances at once. In 2026 that gap is real but not catastrophic at cost 12. If your threat model includes well-funded attackers cracking a stolen database, prefer Argon2id, and our Argon2 tutorial mirrors this project. If you want a vetted, ubiquitous library with the smoothest operational story, bcrypt remains a sound, standards-blessed choice.

5 Common bcrypt Pitfalls That Burn Developers

These mistakes appear in real production code reviews. Each one is avoidable once you know it exists.

  • Ignoring the 72-byte limit. The most common and most dangerous. Without pre-hashing, long passphrases truncate and distinct passwords can collide. Step 5 fixes it; apply the fix before launch, not after.
  • Comparing hashes with ===. Re-hashing a candidate and string-comparing always fails because of the random salt. Only bcrypt.compare verifies correctly, and it runs in constant time.
  • Setting the cost factor too high. A cost of 15 or 16 feels “more secure” but can take seconds per login, turning your auth endpoint into a self-inflicted denial of service. Benchmark to roughly 250 ms.
  • Using a global pepper incorrectly. A static secret appended to every password (a pepper) only helps if stored separately from the database, for example in an HSM or env secret. Concatenating it in plaintext code adds nothing.
  • Blocking the event loop with sync calls. bcrypt.hashSync blocks Node’s single thread. Under load it freezes every request. Always use the async bcrypt.hash and bcrypt.compare, which run on the libuv threadpool.

Troubleshooting Common bcrypt Errors in Node.js

When bcrypt misbehaves, the cause is almost always one of these. Use the table as a fast diagnostic.

SymptomLikely causeFix
node-gyp / build errors on installNo C++ toolchain for native addonInstall build tools, or switch to bcryptjs
Error: data and hash arguments requiredPassing undefined to compareValidate inputs before calling; check req.body parsing
compare always returns falsePre-hashing on hash but not on verify (or vice versa)Use the same prepare() in both paths
Long passwords accept wrong input72-byte truncationPre-hash with SHA-256 to base64 (Step 5)
Login latency spikes under loadUsing hashSync/compareSyncSwitch to async methods
Hashes from old DB do not verifyDifferent scheme (MD5/SHA1) or pepperUse migration wrapper (Step 9)
$2a$ hashes from another stack failCross-language version marker mismatchbcrypt npm verifies 2a/2b/2y; check for input mangling
ERR_REQUIRE_ESM / import errorsMixed CommonJS and ESMSet "type":"module" or use require() consistently
Hash differs every run “by mistake”Expecting deterministic outputCorrect behavior; the random salt is the point

The “different every time” entry trips up beginners constantly. A password hashing function that produced the same output for the same input would be doing it wrong, because that is what salts prevent. Verification works precisely because compare reads the salt back out of the stored hash.

Advanced Tips for Production bcrypt Deployments

Once the basics work, these techniques harden and scale your deployment.

  • Offload hashing under heavy load. bcrypt runs on the libuv threadpool (default size 4). If you authenticate at high volume, raise UV_THREADPOOL_SIZE or move hashing to a dedicated worker pool so it never starves other I/O.
  • Add a separately stored pepper. Combine the SHA-256 pre-hash with an HMAC keyed by a secret held in your secrets manager. If the database leaks but the key does not, every stolen hash is uncrackable.
  • Monitor your cost factor over time. Re-run the benchmark each year on current hardware. When 250 ms no longer corresponds to your chosen cost, bump it and let Step 8 migrate users automatically.
  • Pin and audit the dependency. Treat bcrypt like any security-critical package: lock the version, watch its advisories, and review the changelog before upgrading across a major version.
  • Test against the real database column type. A VARCHAR(50) silently truncates the 60-char hash and breaks every login. Use CHAR(60) at minimum, or a wider TEXT column to be safe.
  • Never roll your own. Do not implement bcrypt, salting, or constant-time comparison by hand. Use the vetted library. Cryptographic primitives are where homegrown code fails most often.

For teams formalizing their approach, the OWASP Password Storage Cheat Sheet is the canonical reference and is updated regularly. Bookmark it and align your config to its current numbers.

Frequently Asked Questions About bcrypt in Node.js

Is bcrypt still secure in 2026?

Yes. bcrypt has no known practical break after more than two decades, and OWASP lists it as an acceptable password hashing function with a work factor of 10 or more. Argon2id is now preferred for new systems because it is memory-hard, but bcrypt at cost 12 remains a defensible production choice in 2026.

Should I use bcrypt or bcryptjs?

Use the native bcrypt (6.0.0) when you can compile a C++ addon, because it is faster. Use bcryptjs (3.0.3) when you need zero build dependencies, such as serverless functions, Alpine containers, or environments without a compiler. Their APIs are nearly identical, so switching is a one-line change.

What cost factor should I use for bcrypt?

Benchmark on your production hardware and pick the highest cost that keeps a single hash near 250 milliseconds. For most 2026 deployments that is 12. OWASP’s floor is 10. Never go below it, and avoid values so high that login takes more than half a second.

Why does bcrypt have a 72-byte password limit?

The Blowfish key schedule bcrypt uses processes at most 72 bytes of key material, so anything beyond that is ignored. It is a byte limit, not a character limit, so multibyte UTF-8 characters count for more. Pre-hash long passwords with SHA-256 to base64 before bcrypt to remove the limit safely.

How do I store a bcrypt hash in a database?

Store the full 60-character output string in a single column; it already contains the algorithm version, cost factor, and salt. Use CHAR(60) or a larger text column. You do not need a separate salt column because the salt is embedded in the hash.

Can I upgrade the cost factor without forcing password resets?

Yes. Because each hash records its own cost, you can detect a below-target cost at login (with bcrypt.getRounds or by parsing the string) and rehash the plaintext you already have in hand. Users upgrade transparently as they sign in, with no reset email required.

Does bcrypt protect against brute-force login attacks?

Only indirectly. bcrypt makes offline cracking of a stolen database expensive, but it does not stop online guessing against your live endpoint. Add rate limiting, account lockout, and breached-password screening to defend the login route itself, as shown in Step 10.

Is SHA-256 enough for storing passwords?

No. SHA-256 is a fast general-purpose hash; a modern GPU computes billions per second, so it cracks stolen password databases quickly. Password storage needs a deliberately slow function like bcrypt, Argon2id, or scrypt. Use SHA-256 only as the fixed-length pre-hash step feeding bcrypt, never on its own.

External references: the OWASP Password Storage Cheat Sheet, the Node.js crypto documentation, the node.bcrypt.js project repository, and the bcrypt technical overview.