🛠️ Implementing JWT Auth
You know what a token is; now you'll make one. In this lesson you build a real Express authentication flow end to end: a user model that hashes passwords with bcrypt, a login route that signs a token with jsonwebtoken, and middleware that reads the Authorization header and guards every protected route behind a verified signature.
Week 9 · Day 2 (Tuesday: JWT Authentication) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure
jsonwebtoken,bcrypt, anddotenvin an Express project - Hash and compare passwords with bcrypt so you never store plaintext
- Sign an access token with
jwt.sign, including a shortexpiresIn - Write auth middleware that parses
Authorization: Bearerand callsjwt.verify - Protect routes and add role-based checks on top of authentication
- Handle expired and invalid tokens with correct 401 responses
Estimated Time: 70 minutes
Project: A minimal auth API with register, login, and a protected profile route.
In This Lesson
The Pieces You Need
A JWT auth system is really three responsibilities working together. Credentials must be verified without ever storing a plaintext password — that's bcrypt. Tokens must be issued and later checked — that's jsonwebtoken. And routes must refuse anyone without a valid token — that's middleware. Everything else is plumbing.
🏨 Analogy: the hotel front desk
Registration is checking in — the desk records who you are (but locks your details in a safe, never on a public board). Login is proving it's you and receiving a room key. The middleware is the electronic lock on every door: it doesn't phone the front desk, it just checks that your key is genuine and hasn't expired. bcrypt guards the safe; the JWT is the key; the lock is your middleware.
Project Setup & Secrets
Start a project and install the essentials. We'll use the modern bcrypt and current jsonwebtoken.
npm init -y
npm install express bcrypt jsonwebtoken dotenv
| Package | Role |
|---|---|
express | The web server and routing |
bcrypt | One-way password hashing and comparison |
jsonwebtoken | Signing and verifying JWTs |
dotenv | Loads secrets from a .env file into process.env |
The signing secret is the single most sensitive value in the whole system — anyone who has it can forge tokens. Keep it out of your code and out of git, in a .env file:
# .env (add this file to .gitignore!)
JWT_SECRET=replace_me_with_a_long_random_string_at_least_32_bytes
JWT_ACCESS_EXPIRATION=15m
PORT=3000
💡 Generate a real secret
Don't type a secret by hand — humans pick weak ones. Generate a cryptographically strong value:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Load it once at the top of your entry file with require('dotenv').config().
Hashing Passwords with bcrypt
Rule zero of authentication: never store a password you could read back. If your database leaks and it holds plaintext passwords, every account is compromised instantly — and because people reuse passwords, so are their accounts elsewhere. Instead you store a one-way hash. bcrypt is purpose-built for this: it's deliberately slow (to resist brute force) and it salts every hash (so identical passwords don't produce identical hashes).
const bcrypt = require('bcrypt');
// When a user registers: hash before saving.
// The number is the "cost factor" — higher is slower and safer. 10-12 is typical.
async function hashPassword(plain) {
const saltRounds = 12;
return bcrypt.hash(plain, saltRounds); // returns e.g. "$2b$12$Xa...."
}
// When a user logs in: compare the attempt to the stored hash.
async function checkPassword(plain, storedHash) {
return bcrypt.compare(plain, storedHash); // resolves true or false
}
Notice you never decrypt anything. bcrypt.compare hashes the incoming attempt with the same salt (which is embedded in the stored hash) and checks whether the results match. For this lesson we'll use a plain in-memory array as our "database" so you can run it with zero setup:
// A stand-in for a real database.
const users = []; // each: { id, email, passwordHash, role }
let nextId = 1;
async function registerUser(email, password, role = 'user') {
const passwordHash = await hashPassword(password);
const user = { id: nextId++, email, passwordHash, role };
users.push(user);
return user;
}
⚠️ bcrypt only, and never log the password
Don't hash passwords with fast, general-purpose functions like MD5 or SHA-256 — their speed is exactly what makes them crackable at scale. Use bcrypt (or argon2/scrypt). And make sure the raw password never lands in a log line or an error message.
Signing Tokens
Once a user's password checks out, you issue a token. Keep the payload lean — an id and a role are plenty. Let the library manage exp for you via expiresIn; it also stamps iat automatically.
const jwt = require('jsonwebtoken');
function signAccessToken(user) {
const payload = {
sub: user.id, // subject: who the token is about
role: user.role, // a private claim your app understands
};
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m',
// Pin the algorithm so it can't be downgraded to "none":
algorithm: 'HS256',
});
}
✅ Why put an id, not an email, in sub
The payload is public (base64url, not encrypted). A numeric id reveals nothing useful if the token is inspected, whereas an email is personal data you'd rather not expose. Look up the full user from the id when you actually need their details.
The Login Route
Now assemble register and login into real Express routes. Two details matter for security: return a generic "invalid credentials" message (don't reveal whether the email exists), and always compare with bcrypt even conceptually to avoid leaking timing.
const express = require('express');
const app = express();
app.use(express.json());
require('dotenv').config();
// POST /register — create an account
app.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required' });
}
if (users.some(u => u.email === email)) {
return res.status(409).json({ message: 'Email already registered' });
}
const user = await registerUser(email, password);
const token = signAccessToken(user);
res.status(201).json({ token });
});
// POST /login — verify credentials and issue a token
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
// Same response whether the email is unknown or the password is wrong:
if (!user || !(await checkPassword(password, user.passwordHash))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const token = signAccessToken(user);
res.json({ token });
});
Trying it out
POST /register { "email": "ada@example.com", "password": "s3cret!" }
→ 201 { "token": "eyJhbGciOiJIUzI1NiI..." }
POST /login { "email": "ada@example.com", "password": "wrong" }
→ 401 { "message": "Invalid credentials" }
Auth Middleware
Middleware is a function that runs before your route handler. The auth middleware's job: pull the token out of the Authorization header, verify it, and either attach the decoded user to req and call next(), or reject the request. Because jwt.verify throws on any problem, wrap it in try/catch and translate the error type into a helpful message.
// middleware/auth.js
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const header = req.headers.authorization;
// Expect exactly: "Bearer <token>"
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token provided' });
}
const token = header.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // allow-list — never trust the token's own header
});
req.user = decoded; // { sub, role, iat, exp }
next();
} catch (err) {
const message =
err.name === 'TokenExpiredError' ? 'Token has expired' : 'Invalid token';
return res.status(401).json({ message });
}
}
module.exports = { requireAuth };
⚠️ jwt.verify, never jwt.decode
jwt.decode just reads the payload — it does not check the signature or expiry, so it will happily hand back a forged token's claims. Only jwt.verify (with an explicit algorithm allow-list) proves the token is genuine. Reaching for decode on a protected route is a classic, dangerous mistake.
Protecting Routes & Roles
Authentication answers "who are you?"; authorization answers "are you allowed?" Stack a small role-checking middleware after requireAuth to gate admin-only routes.
const { requireAuth } = require('./middleware/auth');
// Authorization helper: use AFTER requireAuth so req.user exists.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ message: 'Access denied' });
}
next();
};
}
// Any logged-in user:
app.get('/profile', requireAuth, (req, res) => {
const user = users.find(u => u.id === req.user.sub);
res.json({ id: user.id, email: user.email, role: user.role });
});
// Admins only — two middlewares run in order:
app.get('/admin', requireAuth, requireRole('admin'), (req, res) => {
res.json({ message: 'Welcome to the admin panel' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Auth API running on port ${PORT}`));
💡 401 vs. 403
Return 401 Unauthorized when the request has no valid identity (missing, invalid, or expired token) — "I don't know who you are." Return 403 Forbidden when the identity is valid but lacks permission — "I know who you are, and you can't do this." Getting these right makes your API predictable for clients.
Practice & Quiz
🏋️ Exercise 1: Add a "who am I" route
Goal: Add a protected GET /me route that returns the current user's email and role, plus how many seconds remain until the token expires (using the exp claim already on req.user).
💡 Hint
req.user.exp is a Unix timestamp in seconds. Compare it to Math.floor(Date.now() / 1000). Look the user up by req.user.sub.
✅ Solution
app.get('/me', requireAuth, (req, res) => {
const user = users.find(u => u.id === req.user.sub);
if (!user) return res.status(404).json({ message: 'User not found' });
const nowSeconds = Math.floor(Date.now() / 1000);
const secondsLeft = req.user.exp - nowSeconds;
res.json({
email: user.email,
role: user.role,
secondsUntilExpiry: secondsLeft,
});
});
🏋️ Exercise 2: Spot the vulnerability
Goal: The middleware below is insecure. Find the bug and fix it.
function badAuth(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token); // read the claims
req.user = decoded;
next();
}
💡 Hint
What does decode check that verify checks? And what happens if there is no Authorization header at all?
✅ Solution
Two problems. It uses jwt.decode, which never checks the signature or expiry — a forged token is trusted. And it calls .split on a possibly missing header, crashing the server. The fix uses jwt.verify inside try/catch and guards the header:
function goodAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token provided' });
}
try {
req.user = jwt.verify(header.split(' ')[1], process.env.JWT_SECRET, {
algorithms: ['HS256'],
});
next();
} catch {
return res.status(401).json({ message: 'Invalid token' });
}
}
🎯 Quick Quiz
Question 1: Why hash passwords with bcrypt instead of SHA-256?
Question 2: Which function must protected-route middleware call?
Question 3: A logged-in user hits an admin-only route. What status fits best?
Best Practices & Pitfalls
✅ Do
- Hash with bcrypt at a cost of 10–12; store only the hash
- Keep the secret in
process.env, generated from a CSPRNG, and out of git - Pass an explicit
algorithmsallow-list to bothsignandverify - Give access tokens a short
expiresIn(15 minutes is a common default) - Return generic "invalid credentials" so you don't leak which emails exist
❌ Don't
- Don't store plaintext passwords or log them anywhere
- Don't use
jwt.decodeto protect a route - Don't hard-code the secret or commit your
.env - Don't put sensitive data in the payload — it's readable by anyone
- Don't call
.spliton the header before checking it exists
⚠️ Long-lived access tokens are a trap
It's tempting to set expiresIn: '30d' so users rarely log in again. But a stolen access token is valid until it expires, and JWTs are hard to revoke. The right answer is a short access token paired with a longer refresh token — which is precisely the next lesson.
Summary
🎉 Key Takeaways
- Store passwords as bcrypt hashes, and verify logins with
bcrypt.compare - Issue tokens with
jwt.sign, a lean payload, a shortexpiresIn, and a pinned algorithm - Guard routes with middleware that reads
Authorization: Bearerand callsjwt.verify - Use
verify, neverdecode, and pass analgorithmsallow-list - Separate authentication (401) from authorization (403) with layered middleware
📚 Additional Resources
- jsonwebtoken — full API for
sign,verify, and options - bcrypt on npm — hashing API and cost-factor guidance
- OWASP — Password Storage Cheat Sheet
- MDN — the Authorization header
🚀 What's Next?
Your API issues and checks tokens, but a 15-minute access token would log users out constantly. The next lesson, Token Refresh Strategies, adds long-lived refresh tokens, rotation, and revocation so sessions stay smooth and secure.
🎉 You built real auth!
Register, login, protected routes, and role checks — the backbone of nearly every app you'll ever ship.