NestJS course Β· Module 4: Authentication

Password Hashing - hashing vault codes

5 min read
In this lesson4

Imagine someone carrying a copy of the legion's register out of the fort. If the passwords sit there as plain text, the enemy knows all of them instantly - and because people reuse the same passwords everywhere, their mailboxes and accounts on other services fall too. Architect Vitruvius has a simple rule for this: the vault does not keep keys, only their imprints. That is exactly what password hashing is.

What is Password Hashing?

Hashing turns a password into a string of characters (a hash) using a one-way function. It works like a fingerprint: the same password always produces a matching print, but you cannot rebuild the finger from the print. So at login you do not recover the password from the database - you compute a hash of what the user typed and compare the results.

Why not simple encryption?

Encryption is reversible - whoever holds the key can read everything. Compare the two approaches (the encrypt and decrypt functions are placeholders, used only to illustrate the difference):

1// BAD APPROACH - simple encryption
2const encryptedPassword = encrypt('my-secret-password', 'encryption-key');
3// Can be decrypted: decrypt(encryptedPassword, 'encryption-key')
4
5// GOOD APPROACH - hashing
6const hashedPassword = await bcrypt.hash('my-secret-password', 12);
7// The process cannot be reversed!

A leaked encryption key means every password leaks at once. A hash has no key to steal. Why not plain SHA-256, then? Because it is too fast: a graphics card checks billions of candidates per second. bcrypt is slow on purpose and adds a salt to every password.

Salt and cost

A salt is a random value generated separately for each password. Thanks to it, two legionaries with the password "Roma123" get completely different hashes, and precomputed tables of cracked passwords become useless. The cost (saltRounds) says how much work is needed: bcrypt performs 2^cost iterations, so each extra step doubles the time. This is what a finished 60-character hash looks like:

1$2b$12$FSazelkmW3GJ/HK7ig5K9.1rc20s7WQMF0UJoC58nf5eHWniFUUrm

The $2b$ part is the algorithm version, 12 is the cost, the next 22 characters are the salt, and the last 31 are the hash itself. The salt is stored inside the result, so you do not need a separate database column for it.

Implementation with bcrypt

You install the bcrypt package and the @types/bcrypt typings, and create a service that will be the only place that works with passwords:

1// npm install bcrypt
2// npm install -D @types/bcrypt
3
4import { Injectable } from '@nestjs/common';
5import * as bcrypt from 'bcrypt';
6
7@Injectable()
8export class PasswordService {
9  private readonly saltRounds = 12; // Hashing cost: 2^12 iterations
10
11  async hashPassword(plainPassword: string): Promise<string> {
12    console.log('Creating secure password hash...');
13
14    // bcrypt generates the salt itself and stores it in the result
15    const hashedPassword = await bcrypt.hash(plainPassword, this.saltRounds);
16
17    console.log(`Password hashed: ${hashedPassword.substring(0, 20)}...`);
18
19    return hashedPassword;
20  }

A single bcrypt.hash() call runs the whole process: you pass the cost, the library generates a random salt, combines it with the password and runs 2^12 rounds, and you store the finished string in the database. The password itself is kept nowhere. The asynchronous version computes on the libuv thread pool, so it does not block the server's event loop.

Verification at login looks like this:

1  async verifyPassword(
2    plainPassword: string,
3    hashedPassword: string
4  ): Promise<boolean> {
5    console.log('Verifying password...');
6
7    const isValid = await bcrypt.compare(plainPassword, hashedPassword);
8
9    console.log(`Verification result: ${isValid ? 'password correct' : 'password wrong'}`);
10
11    return isValid;
12  }

bcrypt.compare() reads the salt and the cost from the stored hash, hashes the typed password and compares both results. Nothing is decrypted here - that is impossible.

When you raise the cost to 13 in a few years, old hashes will still say 12. A checking method helps with that:

1  // Check if the password needs re-hashing (change in saltRounds)
2  async needsRehash(hashedPassword: string): Promise<boolean> {
3    try {
4      // The cost (saltRounds) is stored in the hash itself
5      const currentRounds = this.extractSaltRounds(hashedPassword);
6      return currentRounds < this.saltRounds;
7    } catch (error) {
8      return true; // If it cannot be determined, it's better to re-hash
9    }
10  }
11
12  private extractSaltRounds(hash: string): number {
13    // bcrypt format: $2b$12$...
14    const parts = hash.split('$');
15    return parseInt(parts[2], 10);
16  }
17}

Cutting the string manually at the $ sign works, but the library has a ready-made bcrypt.getRounds(hash) - and that is what I recommend. Generate the new hash at the next successful login, because that is the only moment you hold the real password.

The limits of bcrypt

bcrypt reads only the first 72 bytes of its input - UTF-8 bytes, not characters - and silently skips the rest. For passwords this is rarely a problem, but remember the limit: with refresh tokens it will turn out to be crucial. A cost of 12 is a sensible starting point; the NestJS documentation also shows argon2 as an alternative.

In the next lesson you will meet JWT tokens, the passes a legionary receives after a successful login.

Remember: passwords are not encrypted but hashed - the vault keeps the key's imprint, never the key itself.

Code for this lesson: src/auth/password.service.ts
1// Password Hashing - Encrypting the vault codes
2import { Injectable } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4
5@Injectable()
6export class PasswordService {
7  private readonly saltRounds = 12;
8
9  // TODO: Implement password hashing
10  // Use bcrypt.hash() with this.saltRounds
11  async hashPassword(plainPassword: string): Promise<string> {
12    // TODO: Generate the password hash
13    return '';
14  }
15
16  // TODO: Implement password verification
17  // Use bcrypt.compare() to compare
18  async verifyPassword(
19    plainPassword: string,
20    hashedPassword: string,
21  ): Promise<boolean> {
22    // TODO: Compare the password with the hash
23    return false;
24  }
25
26  // Check whether the password needs re-hashing
27  async needsRehash(hashedPassword: string): Promise<boolean> {
28    try {
29      const parts = hashedPassword.split('$');
30      const currentRounds = parseInt(parts[2], 10);
31      return currentRounds < this.saltRounds;
32    } catch {
33      return true;
34    }
35  }
36}
37
38// Demonstration
39async function demo() {
40  const service = new PasswordService();
41
42  const password = 'GloriaRomae2024';
43  console.log('Password:', password);
44
45  const hashed = await service.hashPassword(password);
46  console.log('Hash:', hashed);
47
48  const isValid = await service.verifyPassword(password, hashed);
49  console.log('Correct:', isValid);
50
51  const isWrong = await service.verifyPassword('WrongPassword', hashed);
52  console.log('Incorrect:', isWrong);
53}
54
55demo();
56
57console.log('bcrypt - one-way password hashing');
58console.log('Salt rounds = hashing strength (more = more secure but slower)');
59

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Why should passwords be hashed instead of encrypted?

Hands-on tasks in the game

  • Click in order

    Arrange the steps of password hashing with bcrypt in order:

Useful articles