CodingNic

Token-Based Authentication with JWT

Protecting Routes with a Token

Token-Based Authentication with JWT 20 min read

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

text
jwt-auth-demo/
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ app.js
โ””โ”€โ”€ test.js

Issuing a Token at Login

javascript
// 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:

text
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

javascript
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

javascript
// 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);
})();
text
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

javascript
const noBearer = await request(app).get('/api/v1/me').set('Authorization', token);
console.log(noBearer.status, noBearer.body);
text
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

  1. Build this login and protected-route flow, and confirm the exact sequence of statuses shown above.
  2. Confirm a request with no Authorization header is rejected before jwt.verify() even runs.
  3. Confirm a request with a malformed token is rejected with Invalid or expired token.
  4. 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 role req.session.userId played 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.