RizTech Academy logo
RizTech Academy
Authentication and AuthorisationLesson 3 of 525 min

Storing passwords properly

Storing passwords is the one thing in this course where a mistake has consequences beyond your own application — because people reuse passwords, and a leak from your shop becomes a problem for their email account.

This lesson comes before building login, so the rules are in place first.

Never store the password

await prisma.user.create({ data: { email, password } });          // never

Your database will eventually be read by somebody who should not have it — a backup on a laptop, a misconfigured server, a SQL injection, a disgruntled employee. Plain passwords mean every one of your customers is compromised everywhere they reused it.

Encryption is not the answer either. Encrypted means decryptable, and the key lives near the data.

Hash them. A hash is one-way: you can check whether a password matches without ever being able to recover it.

Not a general-purpose hash

crypto.createHash("sha256").update(password).digest("hex");       // wrong

SHA-256 and MD5 are designed to be fast, which is exactly wrong here. Modern hardware computes billions of SHA-256 hashes per second, so an attacker with your database tries every common password against every row in minutes.

Password hashing needs to be deliberately slow, with a cost you can raise as hardware improves.

Use bcrypt or argon2. Both are designed for this.

npm install bcrypt --workspace=apps/api
npm install -D @types/bcrypt --workspace=apps/api

Hashing and checking

// apps/api/src/auth/password.service.ts
import { Injectable } from "@nestjs/common";
import * as bcrypt from "bcrypt";

const SALT_ROUNDS = 12;

@Injectable()
export class PasswordService {
  hash(plain: string): Promise<string> {
    return bcrypt.hash(plain, SALT_ROUNDS);
  }

  compare(plain: string, hash: string): Promise<boolean> {
    return bcrypt.compare(plain, hash);
  }
}

That is the whole implementation. Two functions, and writing anything more elaborate is a mistake.

$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj/QmRtcMh9a
 │  │  └─ salt + hash
 │  └──── cost: 12
 └─────── algorithm

The salt is generated automatically and stored in the hash itself. You do not manage it, and you do not need a separate column. Hashing the same password twice gives different results, which is the point — identical passwords in your database are not visibly identical.

SALT_ROUNDS = 12 means 2^12 iterations, around 250ms on current hardware. Slow is the feature. 10 is the common default and 12 is a reasonable choice now; raise it as hardware improves.

Do not go much higher: each increment doubles the time, and 15 takes two seconds, which is a denial-of-service risk on your own login endpoint.

Never compare hashes yourself

const hash = await bcrypt.hash(attempt, SALT_ROUNDS);
if (hash === user.passwordHash) { ... }                  // always false

Different salts mean different hashes. bcrypt.compare extracts the salt from the stored hash and uses it — which is why it is the only correct way.

It is also constant-time, so an attacker cannot learn anything from how long the comparison took. A === on secrets can leak information through timing.

The hash must never leave the server

model User {
  id           String @id @default(cuid())
  email        String @unique
  passwordHash String
  name         String
}

From the controllers lesson: never return a raw row.

return this.prisma.user.findUnique({
  where: { id },
  select: { id: true, email: true, name: true, role: true },
});

Structurally safer, with class-transformer:

export class UserEntity {
  id!: string;
  email!: string;

  @Exclude()
  passwordHash!: string;
}

With the serialisation interceptor from module 5, passwordHash cannot be returned even if somebody forgets a select. Make the safe thing the default rather than relying on remembering.

Also keep it out of logs. Logging a whole user object is how a hash reaches your log aggregator.

Password rules that help

The usual advice — an uppercase letter, a number, a symbol — produces Password1! and does not help much. Length matters far more than composition.

import { IsString, MinLength, MaxLength } from "class-validator";

export class RegisterDto {
  @IsEmail({}, { message: "Enter a valid email address" })
  email!: string;

  @IsString()
  @MinLength(10, { message: "Use at least 10 characters" })
  @MaxLength(128)
  password!: string;
}

Minimum 10 characters, which is more useful than composition rules.

A maximum, and it matters technically: bcrypt silently truncates input at 72 bytes. Without a maximum, two different long passwords can hash identically. 128 is a sensible cap that is well under the limit for ordinary input.

Do not forbid spaces or paste. Blocking paste breaks password managers, which pushes people towards weaker passwords they can type.

The genuinely useful check is against known-breached passwords. Have I Been Pwned offers a range-query API that never receives the full password — you send the first five characters of its SHA-1 hash and compare locally. Worth adding to a real shop.

Do not leak which emails exist

if (!user) throw new NotFoundException("No account with that email");
if (!matches) throw new UnauthorizedException("Wrong password");

That lets anyone test whether an email is registered — useful for targeted phishing, and a privacy problem for a shop where the customer list has value.

const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await this.passwords.compare(password, user.passwordHash))) {
  throw new UnauthorizedException("Email or password is incorrect");
}

One message for both.

There is a subtlety: a missing user returns instantly while a real one takes 250ms for the bcrypt check, so timing still reveals the answer. If you care, hash against a dummy value when the user does not exist so both paths take the same time.

The same applies to registration and password reset. "If that address has an account, we have sent a link" reveals nothing.

Rate limit login

Without it, an attacker tries passwords as fast as your server responds.

@Post("login")
@Throttle({ default: { limit: 5, ttl: 60_000 } })
login(@Body() dto: LoginDto) { ... }

Five attempts per minute per IP. Consider also locking an account after repeated failures — with care, because an attacker can then lock out a real user deliberately.

Changing a password

async changePassword(userId: string, current: string, next: string) {
  const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });

  if (!(await this.passwords.compare(current, user.passwordHash))) {
    throw new UnauthorizedException("Your current password is incorrect");
  }

  await this.prisma.$transaction([
    this.prisma.user.update({
      where: { id: userId },
      data: { passwordHash: await this.passwords.hash(next) },
    }),
    this.prisma.refreshToken.deleteMany({ where: { userId } }),
  ]);
}

Require the current password, so a stolen session cannot change it and lock the real owner out.

Delete every refresh token. Changing a password is often a response to suspected compromise, and leaving other sessions alive defeats the point.

Resetting a forgotten password

The correct shape, in brief:

  1. User requests a reset by email
  2. Generate a cryptographically random token — crypto.randomBytes(32), never Math.random()
  3. Store a hash of it with an expiry of about an hour
  4. Email the plain token in a link
  5. On use: hash the supplied token, look it up, check the expiry, reset the password, delete the token and every refresh token
  6. Respond identically whether or not the email exists

Store the reset token hashed, for the same reason as passwords — a leaked database otherwise lets an attacker reset any account.

Check your work

Why not SHA-256: it is fast by design, so an attacker can try billions of guesses per second against a leaked database.

Why bcrypt is slow deliberately: to make brute-forcing expensive, with a cost you can raise over time.

Where the salt lives: inside the hash string. It is generated automatically.

Why bcrypt.compare rather than hashing and comparing: the salt differs per hash, and compare is constant-time so it leaks nothing through timing.

Why a maximum password length: bcrypt truncates at 72 bytes, so without one two different long passwords could hash the same.

Why one message for wrong email and wrong password: otherwise anyone can enumerate which emails have accounts.

Why changing a password should delete refresh tokens: the change is often a response to compromise, so other sessions must end.

Why reset tokens are stored hashed: a leaked database would otherwise allow resetting any account.

Practice

  1. Write PasswordService. Hash the same password twice and confirm the hashes differ.
  2. Confirm compare returns true for both, then explain why.
  3. Hash a password with SHA-256 and time it. Time bcrypt at cost 12. Compare.
  4. Raise the cost to 15 and time it. Consider what that does to your login endpoint under load.
  5. Return a user object including passwordHash from an endpoint. See it in the response, then fix it with select, then with @Exclude.
  6. Write a login that says "no such user" and "wrong password" separately. Use it to determine whether an address is registered. Then fix it.
  7. Time both paths and observe the difference remains. Mitigate it.
  8. Add the throttler to login and exceed it.
  9. Implement password change, including refresh token deletion. Confirm other sessions stop working.
  10. Write down the password reset flow from memory and check it against the list above.

Next: building registration and login.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship