Skip to main content

🛠️ 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, and dotenv in an Express project
  • Hash and compare passwords with bcrypt so you never store plaintext
  • Sign an access token with jwt.sign, including a short expiresIn
  • Write auth middleware that parses Authorization: Bearer and calls jwt.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.

graph TD A["POST /register"] --> B["bcrypt.hash password"] B --> C["Save user to database"] D["POST /login"] --> E["bcrypt.compare password"] E --> F["jwt.sign token"] F --> G["Return token to client"] H["GET /profile"] --> I["Auth middleware"] I --> J["jwt.verify token"] J --> K["Attach req.user, continue"]

🏨 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
PackageRole
expressThe web server and routing
bcryptOne-way password hashing and comparison
jsonwebtokenSigning and verifying JWTs
dotenvLoads 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.

sequenceDiagram participant C as Client participant M as Auth middleware participant H as Route handler C->>M: GET /profile with Authorization Bearer token M->>M: Read header, split off the token M->>M: jwt.verify(token, secret) alt Valid and not expired M->>H: attach req.user, call next() H-->>C: 200 protected data else Missing, invalid, or expired M-->>C: 401 Unauthorized end
// 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 algorithms allow-list to both sign and verify
  • 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.decode to 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 .split on 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 short expiresIn, and a pinned algorithm
  • Guard routes with middleware that reads Authorization: Bearer and calls jwt.verify
  • Use verify, never decode, and pass an algorithms allow-list
  • Separate authentication (401) from authorization (403) with layered middleware

📚 Additional Resources

🚀 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.