The person has an account. Now they must prove who they are, and your server must remember that for the next request. HTTP has no memory: each request stands alone. So after a correct login the server hands the client a token: a small piece of text that the client sends back with every later request, like a wristband at an event.
What a JWT is
A JSON Web Token has three parts separated by dots: a *header*, a *payload* and a *signature*. The payload holds claims such as “the user id is 64f... and this expires in seven days”. The signature is made with a secret that only your server knows. When a token comes back, the server recomputes the signature: if the payload was changed, or the token was made by someone without the secret, the signature will not match and the token is rejected.
import jwt from 'jsonwebtoken';
const secret = () => {
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is missing. Set it in .env');
return process.env.JWT_SECRET;
};
// A JWT has three parts: a header, a payload (we put the user id in `sub`) and a signature made with our secret.
// Anyone can READ the payload, so never put passwords or private data in it. Only we can SIGN it.
export const signToken = (userId) => jwt.sign({ sub: String(userId) }, secret(), { expiresIn: process.env.JWT_EXPIRES_IN || '7d' });
// Throws if the token is forged, tampered with or expired.
export const verifyToken = (token) => jwt.verify(token, secret());
jwt.sign(payload, secret, { expiresIn }) creates a token. We store the user id in sub (short for “subject”), a standard claim name. jwt.verify(token, secret) returns the payload or throws if the token is invalid or expired. The function secret() refuses to run when JWT_SECRET is missing, because a missing secret would silently make every token forgeable.
# Copy this file to .env and fill it in. Never commit .env to Git.
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
MONGODB_URI=mongodb://127.0.0.1:27017/job_tracker
# Make a long random secret with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=7d
Make the secret long and random, as the comment in the file shows. If it is guessable, anyone can create tokens for any user. If you change it, every existing token stops working, which is a handy emergency button after a leak.
The login controller
// Lesson 21: log in and get a token.
import bcrypt from 'bcryptjs';
import User from '../models/User.js';
import { HttpError } from '../utils/HttpError.js';
import { signToken } from '../utils/token.js';
const COST = 12;
// A real hash of nothing in particular. We compare against it when the email is unknown so that
// "wrong email" and "wrong password" take the same time and look the same to an attacker.
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', COST);
const passwordProblem = (password) => {
if (typeof password !== 'string' || password.length < 8) return 'password must be at least 8 characters';
if (password.length > 72) return 'password must be 72 characters or fewer'; // bcrypt reads only 72 bytes
return null;
};
export const register = async (req, res) => {
const { name, email, password } = req.body || {};
const problem = passwordProblem(password);
if (problem) throw new HttpError(400, 'Please fix the highlighted fields', { password: problem });
const passwordHash = await bcrypt.hash(password, COST);
const user = await User.create({ name, email, passwordHash });
res.status(201).json({ token: signToken(user.id), user });
};
export const login = async (req, res) => {
const { email, password } = req.body || {};
const user = typeof email === 'string' && typeof password === 'string'
? await User.findOne({ email: email.trim().toLowerCase() }).select('+passwordHash')
: null;
const ok = await bcrypt.compare(typeof password === 'string' ? password : '', user?.passwordHash || DUMMY_HASH);
if (!user || !ok) throw new HttpError(401, 'Email or password is wrong'); // same message for both cases on purpose
res.json({ token: signToken(user.id), user });
};
- The same message for both failures. If the answer were “no such email” for one and “wrong password” for the other, an attacker could use the form to find out which emails have accounts (called *user enumeration*). So both cases answer
401 Email or password is wrong. - The same time for both failures. Comparing a password with bcrypt takes noticeable time, while “no such user” would return instantly, and that difference can be measured. So when the email is unknown we still call
bcrypt.compareagainst a throw-awayDUMMY_HASH. This is the idea behind a *timing attack* defence. - `.select('+passwordHash')` is how we ask for the hash that is hidden by default.
- Type checks.
typeof email === 'string'stops a client from sending an object instead of text. Lesson 23 will explain why that matters. - Register now also returns a token, so a new person is signed in straight away.
import { Router } from 'express';
import { register, login } from '../controllers/auth.js';
const router = Router();
router.post('/register', register);
router.post('/login', login);
export default router;
Where does the client keep the token?
The React app (lesson 32) will keep the token in localStorage and send it in an Authorization: Bearer <token> header. This is simple and common, and it has a known trade-off: JavaScript on the page can read localStorage, so a cross-site scripting (XSS) bug could steal the token. The alternative, an httpOnly cookie, cannot be read by scripts but needs protection against cross-site request forgery. Neither is free. For this course we use the header approach and keep the pages free of unsafe HTML; lesson 40 lists the cookie version as a next step.
Your task
- Run
npm installinserver(addsjsonwebtoken). Add a longJWT_SECRETto.env. - Log in with the right password and copy the token. Paste it into a JWT decoder and read the payload: find
sub,iat(issued at) andexp(expires). - Change one character of the token and try to
jwt.verifyit in a small script. What error do you get? - Log in with a wrong email and with a wrong password. Compare the status, message and the time taken.
How to check it worked
- A correct login answers 200 with
tokenanduser. - Wrong email and wrong password give the same 401 message.
- The decoded token shows
suband an expiry but no password or email.
Summary
- A JWT is signed, not secret: store only the user id and an expiry in it.
- Sign with a long random secret from
.env; verify on every request. - Answer wrong email and wrong password identically, and take the same time doing it.