Node.jsBackendFull-Stack

JWT Authentication in Node.js & Express

TT
TopicTrick Team
JWT Authentication in Node.js & Express

JWT Authentication in Node.js & Express

Authentication is the front door of your API. Get it wrong and every other security measure you build is irrelevant. JWT (JSON Web Token) authentication is the dominant stateless auth pattern for REST APIs — no sessions, no shared database lookups, just a cryptographically signed token that proves who the caller is.

This module builds a complete JWT auth system: registration, login, protected routes, refresh tokens, and logout — all production-ready.

This is Module 18 of the Node.js Full‑Stack Developer course.


How JWT Authentication Works

text
1. User logs in with email + password
2. Server verifies credentials → signs a JWT with userId + role
3. Server returns: access token (short-lived) + refresh token (httpOnly cookie)
4. Client sends access token in Authorization header on every request
5. Server verifies signature → extracts userId → serves the request
6. When access token expires, client calls /auth/refresh with the cookie
7. Server issues a new access token

No session table. No database lookup per request. Just a signature verification.


Installing jsonwebtoken

bash
npm install jsonwebtoken
npm install --save-dev @types/jsonwebtoken   # if using TypeScript

Token Utilities

js
// lib/tokens.js
import jwt from 'jsonwebtoken';

const ACCESS_SECRET  = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_TTL     = process.env.JWT_ACCESS_TTL  ?? '15m';
const REFRESH_TTL    = process.env.JWT_REFRESH_TTL ?? '7d';

if (!ACCESS_SECRET || !REFRESH_SECRET) {
  throw new Error('JWT secrets must be set in environment variables');
}

export function signAccessToken(payload) {
  return jwt.sign(payload, ACCESS_SECRET, {
    expiresIn: ACCESS_TTL,
    issuer: 'myapp',
  });
}

export function signRefreshToken(payload) {
  return jwt.sign(payload, REFRESH_SECRET, {
    expiresIn: REFRESH_TTL,
    issuer: 'myapp',
  });
}

export function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET, { issuer: 'myapp' });
}

export function verifyRefreshToken(token) {
  return jwt.verify(token, REFRESH_SECRET, { issuer: 'myapp' });
}
text
# .env
JWT_ACCESS_SECRET=your-super-secret-access-key-min-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7d

Generate secure secrets:

bash
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

The Auth Feature

User Model

js
// features/users/users.model.js
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';

const userSchema = new mongoose.Schema(
  {
    name:     { type: String, required: true, trim: true },
    email:    { type: String, required: true, unique: true, lowercase: true },
    password: { type: String, required: true, minlength: 8, select: false },
    role:     { type: String, enum: ['user', 'admin'], default: 'user' },
    tokenVersion: { type: Number, default: 0 },  // for token invalidation
  },
  { timestamps: true }
);

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

userSchema.methods.comparePassword = function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

export const User = mongoose.model('User', userSchema);

Auth Service

js
// features/auth/auth.service.js
import { User } from '../users/users.model.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/tokens.js';
import { AppError } from '../../lib/errors.js';

function buildTokenPayload(user) {
  return {
    sub: user._id.toString(),
    role: user.role,
    version: user.tokenVersion,
  };
}

export async function register({ name, email, password }) {
  const exists = await User.findOne({ email });
  if (exists) throw new AppError('Email already registered', 409);

  const user = await User.create({ name, email, password });
  const payload = buildTokenPayload(user);

  return {
    accessToken: signAccessToken(payload),
    refreshToken: signRefreshToken(payload),
    user: { id: user._id, name: user.name, email: user.email, role: user.role },
  };
}

export async function login({ email, password }) {
  const user = await User.findOne({ email }).select('+password');
  if (!user) throw new AppError('Invalid email or password', 401);

  const valid = await user.comparePassword(password);
  if (!valid) throw new AppError('Invalid email or password', 401);

  const payload = buildTokenPayload(user);

  return {
    accessToken: signAccessToken(payload),
    refreshToken: signRefreshToken(payload),
    user: { id: user._id, name: user.name, email: user.email, role: user.role },
  };
}

export async function refresh(refreshToken) {
  if (!refreshToken) throw new AppError('Refresh token missing', 401);

  let decoded;
  try {
    decoded = verifyRefreshToken(refreshToken);
  } catch {
    throw new AppError('Invalid or expired refresh token', 401);
  }

  // Check tokenVersion to support logout-all
  const user = await User.findById(decoded.sub);
  if (!user || user.tokenVersion !== decoded.version) {
    throw new AppError('Session expired, please log in again', 401);
  }

  const payload = buildTokenPayload(user);
  return { accessToken: signAccessToken(payload) };
}

export async function logout(userId) {
  // Increment tokenVersion — invalidates all refresh tokens for this user
  await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
}

Auth Controller

js
// features/auth/auth.controller.js
import * as authService from './auth.service.js';

const REFRESH_COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
  path: '/api/v1/auth/refresh',     // scope cookie to refresh endpoint only
};

export async function register(req, res, next) {
  try {
    const { accessToken, refreshToken, user } = await authService.register(req.body);
    res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);
    res.status(201).json({ accessToken, user });
  } catch (err) {
    next(err);
  }
}

export async function login(req, res, next) {
  try {
    const { accessToken, refreshToken, user } = await authService.login(req.body);
    res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);
    res.json({ accessToken, user });
  } catch (err) {
    next(err);
  }
}

export async function refresh(req, res, next) {
  try {
    const refreshToken = req.cookies?.refreshToken;
    const { accessToken } = await authService.refresh(refreshToken);
    res.json({ accessToken });
  } catch (err) {
    next(err);
  }
}

export async function logout(req, res, next) {
  try {
    await authService.logout(req.user.sub);
    res.clearCookie('refreshToken', { path: '/api/v1/auth/refresh' });
    res.status(204).send();
  } catch (err) {
    next(err);
  }
}

export async function me(req, res) {
  // req.user is populated by the auth middleware
  res.json(req.user);
}

Auth Router

js
// features/auth/auth.router.js
import express from 'express';
import * as controller from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { registerSchema, loginSchema } from './auth.schema.js';

const router = express.Router();

router.post('/register', validate(registerSchema), controller.register);
router.post('/login',    validate(loginSchema),    controller.login);
router.post('/refresh',                            controller.refresh);
router.post('/logout',   requireAuth,              controller.logout);
router.get('/me',        requireAuth,              controller.me);

export default router;

Validation Schemas

js
// features/auth/auth.schema.js
import Joi from 'joi';

export const registerSchema = Joi.object({
  name:     Joi.string().min(2).max(100).required(),
  email:    Joi.string().email().required(),
  password: Joi.string().min(8).required(),
});

export const loginSchema = Joi.object({
  email:    Joi.string().email().required(),
  password: Joi.string().required(),
});

The Auth Middleware

This is the guard that sits in front of every protected route:

js
// middleware/auth.js
import { verifyAccessToken } from '../lib/tokens.js';
import { AppError } from '../lib/errors.js';

export function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return next(new AppError('Authentication required', 401));
  }

  const token = authHeader.slice(7); // remove "Bearer "

  try {
    const decoded = verifyAccessToken(token);
    req.user = decoded; // { sub, role, version, iat, exp }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return next(new AppError('Access token expired', 401));
    }
    return next(new AppError('Invalid access token', 401));
  }
}

Usage on any router:

js
// Protected routes
router.get('/profile', requireAuth, controller.getProfile);
router.put('/profile', requireAuth, validate(updateSchema), controller.updateProfile);

// Apply to all routes in a router
router.use(requireAuth);

Cookie Parser Setup

The refresh token lives in an httpOnly cookie. Parse it in app.js:

bash
npm install cookie-parser
js
// app.js
import cookieParser from 'cookie-parser';

app.use(cookieParser());

Complete Auth Flow in Practice

1. Register

text
POST /api/v1/auth/register
Body: { "name": "Alice", "email": "alice@example.com", "password": "secret123" }

Response 201:
{
  "accessToken": "eyJhbGci...",
  "user": { "id": "...", "name": "Alice", "email": "alice@example.com", "role": "user" }
}
Set-Cookie: refreshToken=eyJhbGci...; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth/refresh

2. Call a Protected Route

text
GET /api/v1/users/me
Authorization: Bearer eyJhbGci...

Response 200:
{ "sub": "...", "role": "user", "version": 0, "iat": ..., "exp": ... }

3. Refresh the Access Token

text
POST /api/v1/auth/refresh
Cookie: refreshToken=eyJhbGci...   (sent automatically by browser)

Response 200:
{ "accessToken": "eyJhbGci...newtoken..." }

4. Logout

text
POST /api/v1/auth/logout
Authorization: Bearer eyJhbGci...

Response 204 (no body)
Set-Cookie: refreshToken=; expires=Thu, 01 Jan 1970 ...  (cleared)

Token Verification in Error Handler

Add JWT error handling to the global error handler:

js
// middleware/errorHandler.js
import jwt from 'jsonwebtoken';

export function errorHandler(err, req, res, next) {
  if (err instanceof jwt.JsonWebTokenError) {
    return res.status(401).json({ status: 'error', message: 'Invalid token' });
  }
  if (err instanceof jwt.TokenExpiredError) {
    return res.status(401).json({ status: 'error', message: 'Token expired' });
  }

  const status = err.statusCode || 500;
  res.status(status).json({ status: 'error', message: err.message });
}

Security Checklist

PracticeWhy
Use separate secrets for access and refresh tokensCompromise of one does not expose the other
Keep access token TTL short (15 minutes)Limits window of stolen token abuse
Store refresh token in httpOnly cookieJavaScript (XSS) cannot read it
Scope cookie path to the refresh endpointRefresh token is not sent with every API call
Use sameSite: 'strict'Prevents CSRF attacks from using the cookie
Never log or return the raw tokenTokens in logs are a security incident
Always use HTTPS in productionTokens in plaintext are trivially stolen
Embed tokenVersion in payloadEnables instant logout-all
Generate secrets with crypto.randomBytes(64)Brute-force resistant
Set issuer and verify itPrevents token substitution attacks

Node.js Full‑Stack Course — Module 18 of 32

Your API now has complete JWT authentication with refresh tokens. Continue to Module 19 to learn how to hash passwords securely with bcrypt.


    Summary

    JWT authentication requires getting several details right simultaneously:

    • Sign access tokens with a short TTL (15m); sign refresh tokens with a long TTL (7d) using a separate secret
    • Return the access token in the response body and the refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the refresh path
    • The requireAuth middleware extracts the Bearer token, verifies the signature, and attaches req.user — keep it simple and focused
    • Embed tokenVersion in the payload and increment it on logout to invalidate all outstanding refresh tokens
    • Parse cookies with cookie-parser before the router that handles /auth/refresh
    • Handle TokenExpiredError and JsonWebTokenError explicitly — never let them surface as 500 errors
    • Never store sensitive data in the payload — it is encoded, not encrypted

    Continue to Module 19: Password Hashing with bcrypt →