Password Hashing
Passwords must never be stored in a recoverable form. Instead you store a one-way hash produced by a deliberately slow, salted algorithm, then re-hash the submitted password on login and compare the digests. In NestJS this work lives in a service so it is reusable, testable, and easy to swap out. This page shows how to hash on registration and verify on login with both bcrypt and argon2, how to choose work factors, and the pitfalls that quietly weaken an otherwise correct setup.
Why a dedicated password hash
General-purpose hashes like SHA-256 are built to be fast, which is exactly wrong for passwords — speed helps an attacker brute-force a leaked database. Password hashing functions are intentionally expensive and per-password salted, so each guess costs real CPU (and, for argon2, memory) and identical passwords produce different hashes. The two algorithms you should reach for are bcrypt (battle-tested, ubiquitous) and argon2 (the Password Hashing Competition winner, memory-hard, recommended for new projects).
| Property | bcrypt | argon2 (argon2id) |
|---|---|---|
| Maturity | Very high, since 1999 | Modern (2015), PHC winner |
| Cost parameter | rounds (cost factor) | time, memory, parallelism |
| Memory-hard | No | Yes (GPU/ASIC resistant) |
| Input length limit | 72 bytes (silently truncates) | None |
| Recommended for new code | Fine | Preferred |
Installing a hashing library
Pick one library. Both expose a tiny async API and store the salt and parameters inside the resulting hash string, so you do not manage salts yourself.
# bcrypt
npm install bcrypt
npm install -D @types/bcrypt
# or argon2 (no separate types package needed)
npm install argon2
Warning: bcrypt only considers the first 72 bytes of the input. Long passphrases or pre-hashed inputs can collide. argon2 has no such limit; if you stay on bcrypt, pre-hash with SHA-256 and base64-encode before hashing, or cap input length.
Hashing on registration
Wrap hashing in an injectable service so the algorithm is a single swappable dependency. The cost factor — 10–12 rounds for bcrypt on typical 2020s hardware — is a tunable constant. Never log the plaintext password, and strip the hash from anything you return.
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
@Injectable()
export class PasswordService {
private readonly rounds = 12;
async hash(plain: string): Promise<string> {
return bcrypt.hash(plain, this.rounds);
}
async verify(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
}
The argon2 equivalent uses argon2id (the recommended variant) and explicit cost parameters:
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
}
async verify(plain: string, hash: string): Promise<boolean> {
return argon2.verify(hash, plain);
}
}
Register the service in a module and inject it into your UsersService when creating accounts:
import { Injectable } from '@nestjs/common';
import { PasswordService } from './password.service';
import { UsersRepository } from './users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly repo: UsersRepository,
private readonly passwords: PasswordService,
) {}
async register(username: string, password: string) {
const passwordHash = await this.passwords.hash(password);
const user = await this.repo.create({ username, passwordHash });
const { passwordHash: _omit, ...safeUser } = user;
return safeUser; // hash never leaves the service layer
}
}
Verifying on login
On login, fetch the stored hash and call verify(). The library re-derives the hash using the salt and parameters embedded in the stored string, so a parameter change never breaks old hashes. Return the same generic failure for both “no such user” and “wrong password” so attackers cannot enumerate accounts.
async validateUser(username: string, password: string) {
const user = await this.repo.findByUsername(username);
if (!user) {
return null; // identical outcome to a bad password
}
const ok = await this.passwords.verify(password, user.passwordHash);
return ok ? user : null;
}
A stored bcrypt hash is self-describing — the prefix encodes the algorithm and cost:
Output:
$2b$12$Cj1k0qY8m6Yb1fJ2eQ8u.eR4WqX2sZ7nA9bC0dE1fG3hI5jK7lMm
| | | |
| | | +-- 31-char base64 hash
| | +-- 22-char base64 salt
| +-- cost factor (rounds = 12)
+-- algorithm identifier (2b = bcrypt)
Choosing and upgrading work factors
Tune the cost so a single hash takes roughly 100–250 ms on your production hardware — slow enough to deter brute force, fast enough not to stall login under load. Re-measure as hardware improves. Because the cost is stored in the hash, you can transparently upgrade users: after a successful verify, check whether the stored hash used outdated parameters and, if so, re-hash the plaintext you already have in hand.
async verifyAndMaybeRehash(plain: string, user: { id: number; passwordHash: string }) {
const ok = await bcrypt.compare(plain, user.passwordHash);
if (!ok) return false;
// bcrypt.getRounds reads the cost baked into the stored hash
if (bcrypt.getRounds(user.passwordHash) < this.rounds) {
const upgraded = await bcrypt.hash(plain, this.rounds);
await this.repo.updateHash(user.id, upgraded);
}
return true;
}
Tip: Offload hashing to a worker or keep rounds modest if your API is latency-sensitive — bcrypt is CPU-bound and blocks the event loop only briefly per call, but a flood of concurrent logins at high rounds can saturate cores.
Best Practices
- Use
argon2idfor new projects; bcrypt at12rounds remains a sound choice for existing systems. - Let the library generate and embed the salt — never roll your own salt or store it separately.
- Tune cost to about 100–250 ms per hash and re-evaluate as hardware gets faster.
- Compare with the library’s
compare/verify, which are constant-time; never===two hashes. - Never log, return, or serialize the plaintext password or the stored hash.
- Return one generic error for unknown user and wrong password to prevent account enumeration.
- Rehash on successful login when the stored cost is below your current target to upgrade users silently.
- Enforce a minimum password length and rate-limit the login route to blunt brute-force attempts.