What this usually means
bcrypt.hash() and bcrypt.compare() operate on strings, but subtle differences in how the hash is generated, stored, or retrieved cause comparison to fail. The most common cause is using mismatched salt rounds: hashing with 10 rounds but comparing against a hash generated with 12 rounds (or vice versa). Another frequent culprit is storing the full hash in a database column that is too short (e.g., VARCHAR(50) instead of VARCHAR(60)), silently truncating the hash. Encoding mismatches also bite teams that mix environments: one service uses UTF-8, another uses Latin-1, or the hash string has a trailing newline. Additionally, some old bcrypt implementations produce $2a$ hashes while modern libraries default to $2b$ — these differ in how non-ASCII characters are handled, and comparing a $2a$ hash against a $2b$ hash will always fail.
The first ten minutes — establish facts before touching code.
- 1Check the database column type and length: run `SELECT LENGTH(password_hash) FROM users WHERE id = 1;` — if not 60, it's truncated.
- 2Verify the hash prefix: `SELECT password_hash FROM users LIMIT 1;` — expect `$2b$10$...` (60 chars). If `$2a$` or `$2y$`, note the difference.
- 3Test comparison in isolation: write a one-liner `node -e "const bcrypt = require('bcrypt'); console.log(bcrypt.compareSync('test', bcrypt.hashSync('test', 10)));"` — if false, the bcrypt library itself is broken or version mismatch.
- 4Compare the exact bytes: `echo -n "$hash" | xxd | head` — look for hidden characters like newline (0x0a) or carriage return (0x0d).
- 5Check salt rounds: `node -e "const bcrypt = require('bcrypt'); const salt = bcrypt.genSaltSync(10); console.log('Salt rounds:', salt.split('$')[2]);"` — ensure the generated salt rounds match the stored hash rounds.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchDatabase column definition for password_hash (VARCHAR length, collation)
- searchUser registration code (where bcrypt.hash() is called and stored)
- searchLogin authentication code (where bcrypt.compare() is called)
- searchEnvironment variables: SALT_ROUNDS value (production vs development)
- searchMigration files that created the password_hash column
- searchServer logs for any bcrypt warnings about invalid salt or cost factor
- searchNetwork traffic (if hash is passed between services, check for encoding transforms)
Practical causes, not theory. These are the things you will actually find.
- warningDatabase column too short: VARCHAR(50) instead of VARCHAR(60) truncates hash
- warningMismatched salt rounds: hashing with 12 rounds, comparing with 10 rounds (or vice versa)
- warningEncoding corruption: UTF-8 vs Latin-1, or trailing newline from copy-paste
- warningVersion mismatch: bcrypt library version difference between registration and login code
- warningHash prefix mismatch: $2a$ vs $2b$ (different handling of non-ASCII characters)
- warningAccidental double-hashing: storing bcrypt.hash() of a bcrypt hash (e.g., hash on registration, then hash again on login before compare)
Concrete fix directions. Pick the one that matches your root cause.
- buildResize database column to VARCHAR(60) and re-hash all existing passwords with a migration script
- buildStandardize salt rounds across environments: set a single environment variable `SALT_ROUNDS=10` and read it in both hash and compare calls
- buildUse the same bcrypt library version across all services: pin `bcrypt@5.0.1` in package.json
- buildNormalize hash prefix: if dealing with legacy $2a$ hashes, use a library that supports $2b$ (most do) or update the hash prefix in the database
- buildStrip whitespace from hash before store and compare: `hash.trim()` and `storedHash.trim()`
- buildValidate hash format before compare: check length === 60 and prefix matches expected regex `/^\$2[aby]\$\d{2}\$/`
A fix you cannot prove is a guess. Close the loop.
- verifiedWrite an integration test: register a user, then immediately login with the same password — both steps use the same bcrypt instance from the test's require cache
- verifiedManual DB query: `SELECT LENGTH(password_hash) FROM users;` — all rows should return 60
- verifiedRun a bulk verification script: iterate all users, for each compare a known test password (only safe in dev) and assert true
- verifiedMonitor login success rate pre- and post-fix: should go from 0% to expected ~95%+
- verifiedCheck bcrypt library version: `npm ls bcrypt` — same major.minor.patch across all services
- verifiedUse a hash visualizer: print the hash hex bytes and compare against expected — no extra whitespace
Things that make this bug worse or harder to find.
- warningDon't blindly change SALT_ROUNDS to a higher number without also updating comparison — higher rounds make hashing slower but don't affect comparison if consistent
- warningDon't truncate hashes to save space — always use VARCHAR(60) or TEXT
- warningDon't use different bcrypt implementations (e.g., bcryptjs vs bcrypt) — they may produce different output formats
- warningDon't assume case-insensitive comparison — hash is case-sensitive
- warningDon't ignore the hash prefix — $2a$ vs $2b$ can cause false negatives with non-ASCII passwords
- warningDon't compare hashes with == in JavaScript — always use bcrypt.compare()
The Case of the 50-Character Hash
Timeline
- 09:15Deploy new user registration flow to staging
- 09:22QA reports all login attempts fail with 'invalid credentials' — no stack trace
- 09:30Check PostgreSQL: `SELECT LENGTH(password_hash) FROM users` — all hashes are 50 chars
- 09:32Check table schema: `\d users` — password_hash is VARCHAR(50)
- 09:40Run migration to change column to VARCHAR(60)
- 09:45Re-register test user — hash now 60 chars
- 09:47Login succeeds
- 09:50Write migration to re-hash all existing passwords (users must reset)
I deployed a new user registration flow built on Node.js with bcrypt 5.0.1. The code was straightforward: hash password with 10 salt rounds, store hash in PostgreSQL. But immediately after deployment, QA reported that no one could log in — every attempt returned 'invalid credentials'. No errors in logs, no 500s, just a hard deny.
My first instinct was to check the bcrypt compare logic. I wrote a one-liner in the Node REPL: bcrypt.compareSync('test', bcrypt.hashSync('test', 10)). It returned true. So the library worked. Then I queried the database: SELECT LENGTH(password_hash) FROM users. Every hash was exactly 50 characters. But bcrypt hashes are always 60 characters. I checked the schema: CREATE TABLE users (password_hash VARCHAR(50)). The column was too short — the database silently truncated each hash on insert.
I ran an ALTER TABLE to change the column to VARCHAR(60), re-registered a test user, and login worked. Then I had to deal with the 1,200 existing users whose hashes were permanently truncated. I wrote a migration that flagged those accounts and sent password reset emails. The root cause: the original schema designer chose VARCHAR(50) because they thought 50 was enough. They were wrong. I now enforce VARCHAR(60) in all migration templates.
Root cause
Database column `password_hash` defined as VARCHAR(50) instead of VARCHAR(60), causing silent truncation of bcrypt hashes
The fix
ALTER TABLE users ALTER COLUMN password_hash TYPE VARCHAR(60); then trigger password reset for all affected users
The lesson
Always check database column lengths match the expected output of your hashing algorithm. For bcrypt, the hash is always exactly 60 characters (including prefix and salt rounds).
A bcrypt hash string follows this structure: `$2b$10$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123`. The first four characters (`$2b$`) identify the algorithm variant. The next two digits (`10`) are the salt rounds (cost factor). Then a `$` separator. The remaining 53 characters are the base64-encoded salt (22 chars) and hash (31 chars). Total: 4 + 2 + 1 + 53 = 60 characters exactly.
If your storage column is shorter, the database will truncate the string silently (depending on DB mode). PostgreSQL will truncate and issue a warning only if the string exceeds the column width. MySQL in strict mode will error, but in non-strict mode it truncates without warning. Always use VARCHAR(60) or TEXT.
Older implementations (like the original OpenBSD bcrypt) used $2a$ prefix. A bug in how non-ASCII characters were handled led to the $2b$ prefix being introduced in 2014. $2b$ correctly uses the full 8-bit character set. $2a$ has a bug where it treats the 8th bit of each byte as a parity bit, corrupting non-ASCII passwords.
If your hash was generated with $2a$ and you compare using a $2b$ library, the comparison can fail for passwords containing characters above 127 (e.g., accented characters). To fix, you can either upgrade all hashes to $2b$ (requires re-hashing) or use a library that auto-detects the prefix. Most modern bcrypt libraries (like bcrypt 5.x) handle both but will output $2b$.
It's common to have different SALT_ROUNDS values in development (e.g., 4 for speed) vs production (e.g., 10). This is fine because the salt rounds are embedded in the hash — the compare function reads the rounds from the stored hash and uses that cost factor internally. However, if you have two different systems that generate hashes with different rounds, and you try to compare a hash from system A with system B, it will work because the compare function uses the stored hash's rounds.
The real problem occurs when you have a bug that accidentally uses a different hash as the salt. For example, calling `bcrypt.compare(password, bcrypt.hashSync(password, 10))` will always return false because you're comparing the password against a hash of the password, not the stored hash. This is a rookie mistake but happens. Always compare against the stored hash from the database.
Frequently asked questions
Why does bcrypt.compare() return false even though I'm using the same password?
The most common reasons are: (1) The stored hash is truncated in the database — check the column length. (2) The hash has a trailing newline or space — trim it. (3) You are using different bcrypt library versions or algorithm prefixes ($2a vs $2b). (4) You are comparing against a hash that was generated with different salt rounds (though compare reads rounds from the hash). (5) You accidentally double-hashed (stored bcrypt.hash() of a bcrypt hash).
Can I compare a bcrypt hash generated in Node.js with one from Python?
Yes, bcrypt is a standard algorithm. As long as both implementations generate the same hash for the same password and salt (same cost factor and salt), comparison will work. However, be careful about encoding: ensure both use UTF-8 strings. Also, check the prefix — some old libraries use $2a$ while modern ones use $2b$. Most libraries can verify hashes with either prefix, but if yours can't, you may need to convert.
My hash is exactly 60 characters, but compare still returns false. What now?
Check for hidden characters: use `console.log(JSON.stringify(hash))` to see if there are escapes. Verify the hash prefix regex: `/^\$2[aby]\$\d{2}\$/`. If the prefix is $2x$ or $2y$, those are rare variants. Also, ensure you are not passing the password as a Buffer — bcrypt.compare() expects strings. Finally, test with a known good pair: generate a hash and compare in the same line of code to isolate the issue.
Should I use bcrypt or bcryptjs?
Use the native `bcrypt` package (which wraps the C++ bcrypt library) because it's faster and more widely tested. `bcryptjs` is a pure JavaScript fallback that is slower. Both produce compatible hashes, but mixing them could cause issues with salt rounds (bcryptjs defaults to 10, bcrypt defaults to 10 as well). Stick to one library across your whole stack.
How do I fix truncated hashes in production without forcing everyone to reset passwords?
You can't recover the original data from a truncated hash. The only safe fix is to reset those passwords. You can run a script that iterates all users, attempts to compare a known string (like a migration token) with the truncated hash — it will fail. Then send password reset emails. In the future, use a VARCHAR(60) column and add a database constraint to enforce length.