So far anyone can read and change every task. A real app needs accounts, and it needs to know who is making each request. In this lesson you will add registration, login and a check that protects the task routes, so each user sees only their own tasks. Authentication is easy to get subtly wrong, so we will go slowly and explain the reason for each choice.
Rule one: never store a password
A database can be stolen, and staff can read it. If it holds real passwords, every account is at risk, and because people reuse passwords, accounts on other sites too. Instead, store a hash: the result of a one-way mathematical function. You cannot turn a hash back into the password, but you can hash what the user types at login and compare the two. Use a slow function made for passwords, such as bcrypt, which also adds a random salt so that two users with the same password have different hashes. Never use plain SHA or MD5 for passwords, and never invent your own method.
npm install bcryptjs jsonwebtoken
We use bcryptjs, a pure JavaScript version that installs without a compiler on any system. The bcrypt package is faster, but it has native parts. The functions are the same: bcrypt.hash(password, 10) and bcrypt.compare(password, hash). The number 10 is the cost: higher is slower and harder to break.
Rule two: prove who you are with a token
HTTP has no memory. Each request stands alone, so after login, the client needs a way to prove its identity on every later request. One common way is a JSON Web Token (JWT). At login, the server creates a signed text that contains the user's id and an expiry time. The client sends it back in the Authorization header with every request: Authorization: Bearer <token>. The server checks the signature with its secret. If the signature is valid and the token has not expired, the server trusts the contents. Nobody can change the contents without the secret, because the signature would no longer match.
- A JWT is signed, not encrypted. Anyone who holds it can read what is inside. Never put passwords or private data in it.
- The secret must be long and random, and stored only in the environment. Whoever has it can create tokens for any user.
- Give tokens a short life, as we do with
expiresIn: '1h'. A stolen token then stops working soon. - Always use HTTPS in production, so tokens and passwords cannot be read on the way.
The login routes
The router handles registration and login. Read it carefully, especially the comments in the text below the code.
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
export function authRouter({ secret, users }) {
const router = Router();
const sign = (user) => jwt.sign({ sub: user.id, email: user.email }, secret, { expiresIn: '1h' });
const emailOk = (e) => typeof e === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
router.post('/register', async (req, res) => {
const { email, password } = req.body ?? {};
if (!emailOk(email)) return res.status(400).json({ error: 'Enter a valid email' });
if (typeof password !== 'string' || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const key = email.toLowerCase();
if (users.has(key)) return res.status(409).json({ error: 'That email is already registered' });
const user = { id: users.size + 1, email: key, passwordHash: await bcrypt.hash(password, 10) };
users.set(key, user);
res.status(201).json({ token: sign(user) });
});
router.post('/login', async (req, res) => {
const { email, password } = req.body ?? {};
const user = typeof email === 'string' ? users.get(email.toLowerCase()) : undefined;
const ok = user && typeof password === 'string' && (await bcrypt.compare(password, user.passwordHash));
if (!ok) return res.status(401).json({ error: 'Wrong email or password' });
res.json({ token: sign(user) });
});
return router;
}
- The check of the email and password length happens before any work is done. The minimum of 8 characters is a modest rule. Longer is better, and it is wise to allow long passphrases.
- The email is lowercased, so
Asha@Example.comandasha@example.comare the same account. - A duplicate email returns
409 Conflict. - At login, a wrong email and a wrong password give the same message. A different message would tell an attacker which emails have accounts.
await bcrypt.compareis slow on purpose, so guessing is slow.
The check that protects routes
This middleware reads the header, verifies the token and sets req.user. Any route placed after it can trust req.user. If the token is missing or wrong, it answers 401 and the route never runs.
import jwt from 'jsonwebtoken';
export function requireAuth(secret) {
return (req, res, next) => {
const header = req.get('Authorization') || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) return res.status(401).json({ error: 'Log in first' });
try {
const payload = jwt.verify(token, secret);
req.user = { id: payload.sub, email: payload.email };
next();
} catch {
res.status(401).json({ error: 'Token is invalid or expired' });
}
};
}
Each user sees only their own tasks
Logging in is not enough. The routes must also use req.user.id so that one user cannot read or change another user's tasks. In the store, every function now takes the owner's id, and finds a task only if both the id and the owner match. Other users' tasks then look like they do not exist, so the answer is 404 and not 403. Here is the complete store. Compare it with lesson 11: every function now takes ownerId first.
import { readFile, writeFile } from 'node:fs/promises';
// A tiny store that keeps everything in memory and, if you give it a file, saves after each change.
export function createStore(file) {
let tasks = [];
let nextId = 1;
let ready = Promise.resolve();
if (file) {
ready = readFile(file, 'utf8')
.then((text) => {
tasks = JSON.parse(text);
nextId = tasks.reduce((max, t) => Math.max(max, t.id), 0) + 1;
})
.catch((err) => {
if (err.code !== 'ENOENT') throw err; // no file yet is fine, anything else is not
});
}
const save = () => (file ? writeFile(file, JSON.stringify(tasks, null, 2)) : Promise.resolve());
return {
async list(ownerId) { await ready; return tasks.filter((t) => t.ownerId === ownerId); },
async get(ownerId, id) { await ready; return tasks.find((t) => t.id === id && t.ownerId === ownerId); },
async create(ownerId, data) {
await ready;
const task = { id: nextId++, ownerId, title: data.title, done: false, createdAt: new Date().toISOString() };
tasks.push(task);
await save();
return task;
},
async update(ownerId, id, data) {
await ready;
const task = tasks.find((t) => t.id === id && t.ownerId === ownerId);
if (!task) return undefined;
Object.assign(task, data);
await save();
return task;
},
async remove(ownerId, id) {
await ready;
const before = tasks.length;
tasks = tasks.filter((t) => !(t.id === id && t.ownerId === ownerId));
if (tasks.length === before) return false;
await save();
return true;
},
};
}
And the task router. The first argument of each store call is req.user.id, which the middleware set. Create a task, and the owner is stored with it.
import { Router } from 'express';
import { checkTask } from '../validate.js';
export function tasksRouter(store) {
const router = Router();
router.get('/', async (req, res) => {
const list = await store.list(req.user.id);
const { done } = req.query;
if (done === 'true') return res.json(list.filter((t) => t.done));
if (done === 'false') return res.json(list.filter((t) => !t.done));
res.json(list);
});
router.get('/:id', async (req, res) => {
const task = await store.get(req.user.id, Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
router.post('/', async (req, res) => {
const { value, error } = checkTask(req.body);
if (error) return res.status(400).json({ error });
const task = await store.create(req.user.id, value);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', async (req, res) => {
const { value, error } = checkTask(req.body, { partial: true });
if (error) return res.status(400).json({ error });
const task = await store.update(req.user.id, Number(req.params.id), value);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
router.delete('/:id', async (req, res) => {
const removed = await store.remove(req.user.id, Number(req.params.id));
if (!removed) return res.status(404).json({ error: 'Task not found' });
res.status(204).end();
});
return router;
}
The app mounts everything: the auth routes are open, and the task routes sit behind requireAuth.
app.use('/api/auth', authRouter({ secret, users }));
app.use('/api/tasks', requireAuth(secret), tasksRouter(store));
Try the whole flow
# 1. register and keep the token
curl -X POST http://localhost:3100/api/auth/register -H "Content-Type: application/json" -d '{"email":"asha@example.com","password":"correct-horse"}'
# 2. no token: refused
curl -X POST http://localhost:3100/api/tasks -H "Content-Type: application/json" -d '{"title":"x"}'
# 3. with the token: works
curl -X POST http://localhost:3100/api/tasks -H "Authorization: Bearer PASTE_TOKEN_HERE" -H "Content-Type: application/json" -d '{"title":"Learn Express"}'
{"token":"eyJhbGciOiJIUzI1NiIs..."}
{"error":"Log in first"}
{"id":1,"ownerId":1,"title":"Learn Express","done":false,"createdAt":"2026-10-28T04:08:15.718Z"}
The token in your output will be a different long text. We also tested the other cases: registering the same email twice returns 409 with That email is already registered, a wrong password returns 401 with Wrong email or password, and a bad token returns 401. Notice that the task response includes ownerId. For this exercise that is harmless, but in a real API you would decide which fields to send, and leave out internal ones.
What this does not cover
- Logout and revoking tokens. A pure JWT cannot be cancelled before it expires. Short lifetimes help. Bigger apps add refresh tokens or a server-side list.
- Where the client keeps the token. Browser storage is easy but readable by any script on the page. A cookie with the
HttpOnlyflag is safer against script theft but needs protection against cross-site requests. Each choice has trade-offs. Read about both before you build a real front end. - Password reset, email checks, two-step login. Use a tested service or library for these, and do not write them from scratch for real users.
- Roles. Add a
roleto the token for things like admin pages and check it in a middleware.
Your task
- Install
bcryptjsandjsonwebtokenand add the auth router and therequireAuthmiddleware. - Register two users. Create a task as the first user and check that the second user cannot see it, and gets 404 when asking for its id.
- Decode the middle part of a token with
Buffer.from(part, 'base64url').toString()and look at what is inside. Confirm that there is no password in it. - Set
expiresInto'5s', wait, and try again. You should get the message about an expired token.
How to check it worked
- Registering returns 201 and a token, and registering the same email returns 409.
- A request without a token gets 401, and with a valid token it works.
- The second user cannot read, change or delete the first user's tasks.