User Registration and Password Hashing with bcrypt

MERN Stack Full Course: MongoDB, Express, React and Node.js · Authentication and Security · 5 min read · Updated

Never store the password

  1. A person sends an email and a password.
  2. The server checks the email format and a sensible password length first.
  3. The password is run through bcrypt: a one-way function with a random salt, made slow on purpose.
  4. Only the hash is saved. Even we cannot read the password back, and a stolen database does not give the passwords away.
  5. The reply never includes the hash. A duplicate email gives a clear conflict error.

So far anyone can read and change every job. The Job Tracker needs accounts, so each person sees only their own jobs. That requires two ideas that are often confused. Authentication answers “who are you?”. Authorization answers “what are you allowed to do?”. This lesson and the next two build authentication; lesson 22 adds the authorization rule that keeps people apart.

We start with the part that has the largest cost if you get it wrong: storing passwords. The rule is absolute. Never store a password. Store a *hash* of it.

What a hash is, and why bcrypt

A hash function turns any text into a fixed-length scramble, and it only works in one direction: you can get from the password to the hash, but not back. To check a login, you hash what the person typed and compare it with the stored hash. If a thief steals your database, they get hashes, not passwords.

But ordinary fast hashes (such as SHA-256) can be guessed at billions per second by a modern graphics card. Password hashing needs the opposite: it must be deliberately slow. bcrypt was designed for that. It adds a random salt to each password, so two people with the same password get different hashes, and it has a cost setting that makes each guess take real time. We use bcryptjs, a pure JavaScript version that installs everywhere without compiling anything.

import mongoose from 'mongoose';

const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: [true, 'name is required'], trim: true, maxlength: 80 },
    email: {
      type: String, required: [true, 'email is required'], trim: true, lowercase: true, unique: true, // unique creates an index
      match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'email is not valid'],
    },
    // select: false = queries do NOT return the hash unless you ask for it with .select('+passwordHash')
    passwordHash: { type: String, required: true, select: false },
  },
  { timestamps: true },
);

userSchema.set('toJSON', {
  virtuals: true,
  versionKey: false,
  transform: (doc, ret) => { delete ret._id; delete ret.passwordHash; return ret; },
});

export default mongoose.model('User', userSchema);
  • `email` is lowercase and trimmed, so Ana@Example.com and ana@example.com are the same account, and unique: true creates a unique index so MongoDB itself refuses a second account with the same email.
  • `match` is a light format check. Do not try to validate email addresses perfectly with a regular expression; the only real proof an address works is sending a message to it (a feature for later).
  • `select: false` on passwordHash means that queries do not return it unless you explicitly ask with .select('+passwordHash'). A forgotten .select can then never leak it.
  • The toJSON transform deletes passwordHash as a second safety net, in case a document with the hash ever gets sent.

The register controller

// Lesson 20: sign up with a hashed password. (Login arrives in lesson 21.)
import bcrypt from 'bcryptjs';
import User from '../models/User.js';
import { HttpError } from '../utils/HttpError.js';

const COST = 12; // how slow hashing is on purpose. Higher = safer but slower; 10 to 12 is common today

export const register = async (req, res) => {
  const { name, email, password } = req.body || {};
  if (typeof password !== 'string' || password.length < 8) {
    throw new HttpError(400, 'Please fix the highlighted fields', { password: 'password must be at least 8 characters' });
  }
  if (password.length > 72) { // bcrypt only reads the first 72 bytes
    throw new HttpError(400, 'Please fix the highlighted fields', { password: 'password must be 72 characters or fewer' });
  }
  const passwordHash = await bcrypt.hash(password, COST);
  const user = await User.create({ name, email, passwordHash }); // a duplicate email is a 409 thanks to the error handler
  res.status(201).json({ user });
};

The password is checked before hashing: at least 8 characters, and at most 72. The upper limit is a bcrypt detail: it only reads the first 72 bytes, so a longer password adds nothing, and a very long one wastes server time. bcrypt.hash(password, 12) produces the hash. The number 12 is the cost: each increase of 1 doubles the time. Around 10 to 12 is common today, and it should be raised over the years as computers get faster.

User.create fails if the email already exists, and MongoDB reports that with the error code 11000. The error handler from lesson 17 turns that into 409 Conflict with a message for the email field, so a registration form can show “That email is already used”.

import { Router } from 'express';
import { register } from '../controllers/auth.js';

const router = Router();
router.post('/register', register);
export default router;

Your task

  1. Run npm install in server (it adds bcryptjs), then register with POST /api/auth/register and a JSON body of name, email and password.
  2. Look at the users collection in Atlas. Find the passwordHash and see that it starts with $2a$12$.
  3. Register the same email again. What status do you get? Try ANA@example.com in capitals.
  4. Register the same password for two different emails and compare the two hashes. Why are they different?

How to check it worked

  • A good registration answers 201 with the user and no passwordHash.
  • A short password answers 400 with a message for password.
  • A repeated email answers 409.

Summary

  • Authentication is who you are; authorization is what you may do.
  • Store a slow, salted hash (bcrypt), never the password.
  • Make emails lowercase and unique; hide the hash by default and in JSON.

Download the solution (mern-lesson-20.zip)

Frequently asked questions

Why not encrypt passwords instead of hashing them?

Encryption can be reversed with the key, so a thief who gets the key and the data gets every password. A hash cannot be reversed. You only ever need to check a guess against it, never to read the original.

What is the bcrypt cost factor?

It controls how slow hashing is. Each increase of 1 doubles the time. Pick a value that takes roughly a tenth of a second or more on your server (10 to 12 is common) and raise it as hardware improves.

Why bcryptjs and not bcrypt?

bcryptjs is pure JavaScript and installs on any system with no build tools. The native bcrypt package is faster but needs compilation. For most apps the difference does not matter. The hashes are compatible.