Hashing with bcrypt
In the last lesson we talked ourselves into using a slow, salted, one-way hash for passwords. Now we are going to stop talking and actually do it. This is where the abstract idea becomes a real library call: we install bcrypt, hash a password, verify it, and see what comes out. This will be the first real piece of authentication code in the course, and once you understand it, the signup and login routes we write next will feel obvious.
Why bcrypt specifically
bcrypt is one of the most widely used password hashing algorithms in the world. It has been around since 1999, it has been studied by actual cryptographers for a long time, and it is available in every programming language you have ever heard of. For our purposes, three things make it great:
- It generates a random salt for you. You do not have to manage salts yourself, which is one less thing to get wrong.
- It hashes the password with that salt, using a cipher internally to do the work.
- It has a cost factor you can crank up, so you can make it slower as computers get faster.
There are newer algorithms like Argon2 that are arguably better in certain scenarios, and if you ever work on a project that specifically calls for them, great. But bcrypt is the safe, proven, boring-in-a-good-way choice, and boring is usually what you want for security-sensitive code.
Install bcrypt
npm install bcryptjs
npm install -D @types/bcryptjs Quick heads-up: we are installing bcryptjs, not bcrypt. There are two packages. The one without the “js” uses native C++ bindings for slightly better performance but requires a C++ compiler on your system to install, which causes endless setup headaches for people on Windows or certain Docker images. bcryptjs is a pure JavaScript implementation with the same API. It is a bit slower, but nobody is going to notice, and it just works everywhere. Go with bcryptjs.
The -D flag on the second line installs the TypeScript types, just for our editor’s benefit.
Hashing a password
Here is the most basic possible example. Go ahead and play with it in a scratch file:
import bcrypt from "bcryptjs";
const password = "hunter2";
const hash = await bcrypt.hash(password, 10);
console.log(hash);
// $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy Two arguments, one line of real work. The first argument is the password itself. The second one, that 10, is the cost factor. Let’s talk about it for a second because it matters.
bcrypt runs the hash function in a loop. The number of iterations is 2^cost. So a cost of 10 means 2^10 = 1,024 rounds. A cost of 12 means 2^12 = 4,096 rounds. A cost of 14 means 16,384. Every step up roughly doubles the time it takes.
Higher cost means slower hashing, which makes brute-force attacks harder, but it also means logins are slower for your actual users. A cost of 10 is the standard default. On modern hardware it takes roughly 100 milliseconds, which your users will never notice but which ruins any attacker trying to guess a billion passwords. If you are paranoid or your users are particularly important targets, bump it to 12. Do not go wild though. A cost of 20 would take multiple seconds per login, which makes your app feel broken and opens you up to denial-of-service issues.
Reading a bcrypt hash
Take a close look at the output string. It is not just random characters. It is actually structured:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ │ │
│ │ └── salt (22 characters) │
│ └── cost factor (10) │
└── algorithm version (2a) hash (31 characters) The hash string contains three pieces of information packed together: the algorithm version, the cost factor, and the salt. Then the actual hash at the end.
Why does this matter? Because when you verify a password later, bcrypt can read all of that straight out of the stored hash. It pulls out the salt, pulls out the cost, applies them to the password attempt, and compares. You never have to store the salt in a separate database column. You never have to think about it at all. It is just part of the output string.
This is one of the small, quiet reasons bcrypt is lovely to work with. A lot of things that feel like they should be complicated are already handled for you.
Verifying a password
const password = "hunter2";
const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
const matches = await bcrypt.compare(password, hash);
console.log(matches); // true
const wrong = await bcrypt.compare("wrongpassword", hash);
console.log(wrong); // false bcrypt.compare() takes the plaintext password (whatever the user just typed) and the stored hash (whatever you pulled out of your database). It handles all the extraction internally: it reads the salt and cost from the hash string, hashes the attempt using those same parameters, and checks whether the result matches. You get back a boolean.
Notice how symmetric this is. You never touch the salt yourself. You just pass the attempt and the stored hash in, and bcrypt does the rest.
One weird thing: the same password hashes differently every time
This is the part that catches almost everyone off guard. Watch:
const hash1 = await bcrypt.hash("hunter2", 10);
const hash2 = await bcrypt.hash("hunter2", 10);
console.log(hash1 === hash2); // false! Same password. Same cost. Different hashes. What is going on?
Remember that bcrypt generates a new random salt every time you call hash(). So each call produces a different salt, and therefore a different hash. Both are valid representations of the password "hunter2". Neither is “wrong.”
This is important because it means you cannot verify a password by hashing the attempt and comparing strings. You have to use bcrypt.compare(). Why? Because compare knows how to extract the salt from the stored hash and apply that specific salt to the attempt. A fresh hash with a fresh salt would not match, even if the password is identical. Always verify with compare, never with ===.
Putting it together
Here is the whole thing in one block, showing signup-then-login:
import bcrypt from "bcryptjs";
// Signup: hash the password and store it
const passwordHash = await bcrypt.hash("hunter2", 10);
// You would then store passwordHash in your database alongside the user's email
// Login: pull the stored hash out of the database, then verify
const storedHash = passwordHash; // pretend this came from the database
const attempt = "hunter2"; // this is what the user just typed
const valid = await bcrypt.compare(attempt, storedHash);
if (valid) {
console.log("Login successful");
} else {
console.log("Wrong password");
} That is the complete mechanism in about six lines. Hash on signup. Compare on login. Never store the plain password anywhere, ever.
Exercises
Exercise 1: Create a file called hash-test.ts in your src directory. Hash the password "hello world" with a cost of 10, print the hash, then verify it with bcrypt.compare. Run it with npx tsx src/hash-test.ts.
import bcrypt from "bcryptjs";
const hash = await bcrypt.hash("hello world", 10);
console.log("Hash:", hash);
const valid = await bcrypt.compare("hello world", hash);
console.log("Valid:", valid); // true
const invalid = await bcrypt.compare("goodbye world", hash);
console.log("Invalid:", invalid); // false Exercise 2: Hash the same password three times and print all three hashes. Verify that all three are different strings, but bcrypt.compare returns true for all of them. This drives home that compare is what you have to use, not string equality.
import bcrypt from "bcryptjs";
const password = "same-password";
const h1 = await bcrypt.hash(password, 10);
const h2 = await bcrypt.hash(password, 10);
const h3 = await bcrypt.hash(password, 10);
console.log(h1);
console.log(h2);
console.log(h3);
// All different strings
console.log(await bcrypt.compare(password, h1)); // true
console.log(await bcrypt.compare(password, h2)); // true
console.log(await bcrypt.compare(password, h3)); // true Exercise 3: Measure how long hashing takes at different cost factors. Try costs 8, 10, 12, and 14. You should see each step roughly double the time. This is the cost knob we talked about, made real.
import bcrypt from "bcryptjs";
for (const cost of [8, 10, 12, 14]) {
const start = Date.now();
await bcrypt.hash("test-password", cost);
console.log(`Cost ${cost}: ${Date.now() - start}ms`);
} Now that we can safely hash passwords, we finally have enough tools to build our first real authentication endpoint. In the next lesson, we write a POST /signup route that accepts an email and password, validates them, hashes the password, and stores the user.
Why does hashing the same password twice produce different results?
What cost factor would you use for a production application?