Login with JWT: Sign In, Issue a Token and Avoid Timing Leaks

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

Log in and get a token

  1. The client sends the email and the password.
  2. The server finds the user and compares the password with the stored hash.
  3. If the email is unknown, a dummy hash is compared anyway, so the timing does not reveal which emails exist.
  4. On success the server signs a JSON Web Token with a secret. It holds the user id and an expiry time.
  5. The token goes back to the client, which sends it with later requests. The same message is used for every login failure.

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.compare against a throw-away DUMMY_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

  1. Run npm install in server (adds jsonwebtoken). Add a long JWT_SECRET to .env.
  2. 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) and exp (expires).
  3. Change one character of the token and try to jwt.verify it in a small script. What error do you get?
  4. 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 token and user.
  • Wrong email and wrong password give the same 401 message.
  • The decoded token shows sub and 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.

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

Frequently asked questions

Is a JWT encrypted?

No. It is signed, so it cannot be changed without detection, but anyone holding it can read the payload. Keep sensitive data out of it.

How long should a token last?

Short enough that a stolen one stops working soon, long enough that people are not signed out all the time. Seven days is a common simple choice for small apps. Bigger systems use short-lived access tokens plus refresh tokens.

Why not say "email not found" on login?

It tells an attacker which email addresses have accounts, which helps phishing and guessing. A single message for every failure reveals nothing.