28 July 2026

The Dollar Sign That Broke My Bcrypt Hash

Password verification for the dashboard login started failing with no code change anywhere near the auth path. The hash in .env looked correct — copy-pasted straight from the generator script — and yet bcrypt.compare() returned false every time.

What a bcrypt hash actually looks like

A bcrypt hash is full of dollar signs by design — they're field separators in the hash format itself:

$2b$10$N9qo8uLOickgx2ZMRZoMye...

Where it actually broke

Next.js's env loading does variable-expansion on values that look like $SOMETHING, the same way a shell would. A hash segment like $2b isn't just a string to the loader — depending on what follows, it can be read as a reference to an environment variable named 2b, which doesn't exist, and gets silently expanded to an empty string. The hash that reaches process.env.ADMIN_PASSWORD_HASH at runtime is quietly shorter and different from the one written in the file.

The fix, and why it's easy to miss

Escaping every $ as \$ in the .env file stops the expansion:

ADMIN_PASSWORD_HASH=\$2b\$10\$N9qo8uLOickgx2ZMRZoMye...

It's easy to miss because the failure has no stack trace pointing at .env — it surfaces as "login broken," which points every instinct at the auth code, the bcrypt version, or the database, and never at the file that looks like static configuration.

The general lesson: any secret that contains $ — hashes, some generated tokens, certain base64 output — is not safe to paste into a dotenv file unescaped. Worth checking before the next password reset, not after it locks you out.