Protecting Routes with a Token
Objectives
By the end of this lesson, you should be able to:
- Issue a JWT at login, in place of creating a session
- Read a token from the Authorization header
- Build middleware that verifies a token and protects a route
๐ก Why this matters: This lesson rebuilds Module 2’s login and protected-route flow, stateless, no session store, no cookie, just a token the client holds and sends with every request.
โ ๏ธ A note on verification: every snippet and every output in this lesson was actually run, with real HTTP requests against a real Express app.
Project File Structure
jwt-auth-demo/
โโโ package.json
โโโ app.js
โโโ test.js
Issuing a Token at Login
// app.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET || 'dev-only-secret';
const users = [];
let nextId = 1;
(async () => {
users.push({ id: nextId++, email: 'erin@example.com', passwordHash: await bcrypt.hash('sunshine123', 10) });
})();
app.post('/api/v1/auth/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'InvalidCredentials', message: 'Incorrect email or password' });
}
const token = jwt.sign({ userId: user.id, email: user.email }, SECRET, { expiresIn: '1h' });
res.json({ token });
} catch (err) {
next(err);
}
});
Compare this to Module 2’s login: the credential check (Module 1) is identical, only the last step changed, req.session.userId = user.id became jwt.sign(...), returning the token directly in the response body instead of setting a cookie.
The Authorization Header
A JWT is sent by the client on every request, in the Authorization header, using the Bearer scheme:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
There’s no cookie involved at all, the client (a mobile app, a single-page app, another service) is responsible for storing the token after login and attaching this header to every subsequent request.
Verifying the Token in Middleware
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized', message: 'Missing or malformed Authorization header' });
}
const token = authHeader.slice('Bearer '.length);
try {
req.user = jwt.verify(token, SECRET);
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token' });
}
}
app.get('/api/v1/me', requireAuth, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
res.json({ id: user.id, email: user.email });
});
module.exports = app;
req.user here plays the exact same role req.session.userId played in Module 2, the verified identity of whoever made the request, available to any route handler downstream. The difference is where it comes from: decoded straight from the token itself (Lesson 2), no session store lookup at all.
Testing the Full Flow
// test.js
const request = require('supertest');
const app = require('./app');
(async () => {
const noToken = await request(app).get('/api/v1/me');
console.log('No token:', noToken.status, noToken.body);
const login = await request(app).post('/api/v1/auth/login').send({ email: 'erin@example.com', password: 'sunshine123' });
const { token } = login.body;
const withToken = await request(app).get('/api/v1/me').set('Authorization', `Bearer ${token}`);
console.log('With token:', withToken.status, withToken.body);
const badToken = await request(app).get('/api/v1/me').set('Authorization', 'Bearer garbage.token.here');
console.log('Bad token:', badToken.status, badToken.body);
})();
No token: 401 { error: 'Unauthorized', message: 'Missing or malformed Authorization header' }
With token: 200 { id: 1, email: 'erin@example.com' }
Bad token: 401 { error: 'Unauthorized', message: 'Invalid or expired token' }
Note there’s no agent, no cookie persistence at all here, unlike Module 2’s tests, every request in this flow is independent, carrying its own proof of identity in the Authorization header, exactly the “stateless” property from Lesson 1.
A Missing Bearer Prefix
const noBearer = await request(app).get('/api/v1/me').set('Authorization', token);
console.log(noBearer.status, noBearer.body);
401 { error: 'Unauthorized', message: 'Missing or malformed Authorization header' }
Sending the raw token without the Bearer prefix is rejected before jwt.verify() is ever called, authHeader.startsWith('Bearer ') catches it first.
Sessions Versus JWTs: Choosing Between Them
Both patterns solve the same problem, this course covers both because real projects use both, depending on the situation:
| Sessions (Module 2) | JWT (this module) | |
|---|---|---|
| Where identity lives | Server-side store | Inside the token itself |
| What the client holds | A cookie (session ID) | A token (all claims) |
| Server lookup per request | Yes, checks the session store | No, pure verification |
| Natural fit | Traditional server-rendered web apps | REST APIs, mobile apps, multiple services |
| Instant revocation | Easy, destroy the session | Harder, a token stays valid until it expires |
Try It
- Build this login and protected-route flow, and confirm the exact sequence of statuses shown above.
- Confirm a request with no
Authorizationheader is rejected beforejwt.verify()even runs. - Confirm a request with a malformed token is rejected with
Invalid or expired token. - Explain, in your own words, one situation where session-based auth is the better fit, and one where JWT is, based on the comparison table above.
Recap
- A JWT is issued at login (
jwt.sign()) and returned directly to the client, no session or cookie involved. - The client sends it back on every request in the
Authorization: Bearer <token>header. - Middleware reads and verifies the token, attaching the decoded claims to
req.user, the same rolereq.session.userIdplayed for sessions.
This is the final lesson of this module before exercises. Next module: authorization, restricting specific routes to specific roles, on top of the authentication built in Modules 2 and 3.