Skip to content

Password hashing

Real persistence in apps/api/src/users/users.service.ts, replacing the echo-only stub from Validation. UsersService.create now hashes the incoming password with bcrypt and saves a real User document; findByEmail and findById give the rest of this module the lookups login and route guards need. UsersModule gets a MongooseModule.forFeature registration for the User schema from Schemas, and UsersController returns the saved user with passwordHash stripped out.

A password is the one field in this entire app that must never be recoverable — not by a developer reading the database, not by an attacker who dumps it. Hashing solves that: bcrypt.hash(password, 12) runs the password through a one-way function and returns a string that encodes the algorithm version, the cost factor, a random salt, and the hash itself, all in one field — which is exactly why User.passwordHash in Schemas is a single string column with no separate salt field next to it. Verifying a login later never reverses this; JWT & Passport calls bcrypt.compare(candidatePassword, user.passwordHash), which re-hashes the candidate with the salt already embedded in the stored value and compares the results.

The 12 in bcrypt.hash(password, 12) is the cost factor — each increment doubles the work bcrypt does per hash. It’s not a fixed number of iterations; it’s 2^cost rounds of an intentionally slow key-derivation function, tuned so hashing one password takes a noticeable fraction of a second on real hardware, and brute-forcing millions of candidate passwords takes proportionally longer. 12 is the current common default (bcrypt’s own docs have used it as their example cost factor for years) — high enough to be expensive for an attacker with a stolen database dump, low enough that a real login request doesn’t visibly lag.

UsersService.create also has to guard against a second registration attempt for the same email before it ever reaches MongoDB’s unique: true index on email. Relying on the database constraint alone would mean a duplicate signup surfaces as a raw driver error (a Mongo E11000 duplicate key exception) instead of a clean 409 Conflict — so create checks findByEmail first and throws ConflictException itself, the same “throw a built-in Nest exception, let the global filter format the response” pattern Config & exceptions established for every other error in this app.

bcrypt vs. argon2 vs. plaintext. Plaintext isn’t a real option — it’s listed here only because it’s the mistake this lesson exists to prevent: a stolen database dump becomes a stolen password list, and because people reuse passwords, a breach on this app compromises accounts on other, unrelated services too. Between the two real choices: argon2 (specifically argon2id) is the more modern, memory-hard algorithm — it’s deliberately expensive in RAM as well as CPU time, which resists the cheap parallel attacks GPUs and ASICs are good at, and it won the 2015 Password Hashing Competition on exactly that basis. bcrypt is older, CPU-hard only, and has no memory-hardness — but it’s had over 25 years of production scrutiny, ships with fewer native-build headaches across hosting platforms, and its embedded-salt, self-describing hash format is what every mainstream framework (including Nest’s own recipes) reaches for first. For DevBlog, with a small, low-value user base (authors and one admin, not a bank), bcrypt’s battle-tested simplicity outweighs argon2’s stronger resistance to large-scale offline attacks — but a production system defending a larger, higher-value user base should default to argon2id instead.

Cost factor 12 vs. a higher number. A higher cost factor (14, 16) makes offline brute-forcing proportionally more expensive if the database ever leaks — but it costs real, linear latency on every single login and registration request, since the server has to run the same expensive hash to verify a password as it did to create one. 12 is a deliberate middle point: expensive enough to matter against an attacker, cheap enough that POST /users below still responds in well under a second.

Create apps/api/src/users/users.service.ts:

import { ConflictException, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import * as bcrypt from 'bcrypt';
import { User, UserDocument } from './schemas/user.schema';
const SALT_ROUNDS = 12;
export interface CreateUserInput {
email: string;
password: string;
displayName: string;
}
@Injectable()
export class UsersService {
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
) {}
async create(input: CreateUserInput): Promise<UserDocument> {
const existing = await this.findByEmail(input.email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS);
const user = new this.userModel({
email: input.email,
passwordHash,
displayName: input.displayName,
});
return user.save();
}
findByEmail(email: string): Promise<UserDocument | null> {
return this.userModel.findOne({ email }).exec();
}
findById(id: string): Promise<UserDocument | null> {
return this.userModel.findById(id).exec();
}
}
  • SALT_ROUNDS = 12 — named and hoisted so the cost factor is a single, documented value instead of a magic number buried in a function call.
  • bcrypt.hash(input.password, SALT_ROUNDS) — generates a random salt internally and returns one self-contained string (algorithm tag, cost, salt, hash). Nothing else needs to be stored alongside it.
  • findByEmail before create — turns a duplicate signup into a deliberate 409 Conflict instead of letting a raw MongoDB E11000 error reach AllExceptionsFilter as an unhandled 500.
  • findById exists now because Guards & roles and Auth resolver & GraphQL setup both need to load the full user behind a decoded JWT payload, which only carries the user’s id.

Update apps/api/src/users/users.module.ts to register the User schema and export UsersService so JWT & Passport can inject it into AuthModule:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from './schemas/user.schema';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

Update apps/api/src/users/users.controller.tscreate is now async and returns the saved user with passwordHash left out, never echoed back to the client:

import { Body, Controller, Post } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
async create(@Body() dto: CreateUserDto) {
const user = await this.usersService.create(dto);
return {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role,
};
}
}

CreateUserDto from Validation already has exactly the email, password, displayName shape UsersService.create’s CreateUserInput expects — no changes needed there.

Terminal window
npm run start:dev

A registration request now persists a real, hashed user instead of echoing the request body:

Terminal window
curl -i -X POST localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}'
HTTP/1.1 201 Created
{"id":"...","email":"author@example.com","displayName":"Ava","role":"author"}

Confirm the stored value is a bcrypt hash, not the plaintext password, directly against the mongo container from Compose skeleton:

Terminal window
docker compose exec mongo mongosh -u devblog -p devblog --authenticationDatabase admin devblog \
--eval "db.users.findOne({ email: 'author@example.com' }, { passwordHash: 1 })"
{ _id: ..., passwordHash: '$2b$12$K8Z...' }

$2b$12$ is bcrypt’s own format tag — version 2b, cost factor 12 — confirming SALT_ROUNDS took effect and the field genuinely isn’t recoverable plaintext.

Registering the same email twice now fails clean instead of a raw driver error:

Terminal window
curl -i -X POST localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"email":"author@example.com","password":"correct-horse","displayName":"Ava"}'
HTTP/1.1 409 Conflict
{"statusCode":409,"timestamp":"...","path":"/users","message":"Email already registered"}

UsersService.create hashes every incoming password with bcrypt.hash(password, 12) before it ever touches a User document — the cost factor is a named constant, not a magic number, and the resulting string is self-contained (salt included) so no separate salt column is needed. findByEmail guards against duplicate registrations with a clean 409 instead of letting MongoDB’s unique index throw a raw driver error, and findById exists ahead of need for the JWT-backed lookups the rest of this module builds. UsersController now returns the persisted user with passwordHash stripped — the shape Auth resolver & GraphQL setup later mirrors exactly as the GraphQL User type.

Next: JWT & Passport →