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
lowercaseandtrimmed, soAna@Example.comandana@example.comare the same account, andunique: truecreates 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
passwordHashmeans that queries do not return it unless you explicitly ask with.select('+passwordHash'). A forgotten.selectcan then never leak it. - The
toJSONtransform deletespasswordHashas 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
- Run
npm installinserver(it addsbcryptjs), then register withPOST /api/auth/registerand a JSON body of name, email and password. - Look at the
userscollection in Atlas. Find thepasswordHashand see that it starts with$2a$12$. - Register the same email again. What status do you get? Try
ANA@example.comin capitals. - 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.