Signing and Verifying JWTs in Express

The JWT layer in your Express API is built around two operations: signing โ€” creating a new token when a user logs in or registers โ€” and verifying โ€” checking that a token on an incoming request is valid. The jsonwebtoken package handles both. In this lesson you will build the complete signing and verification code, wire it into your auth controllers and middleware, and handle every error scenario โ€” expired tokens, invalid signatures, and missing tokens โ€” with the correct HTTP status codes and error messages.

Installing jsonwebtoken

cd server
npm install jsonwebtoken

Environment Variables for JWT

# server/.env
JWT_SECRET=b3a8f25c1e4d6a9b2f0e7c3d5a8f1b4e2c6d9a0f3b7e1c4d8a2f5b9e3c7d1a0f4
JWT_EXPIRES_IN=7d           # development
JWT_REFRESH_SECRET=a9c3f1b7e5d2a0f4b8e6c1d4a7f2b5e9c3d1a8f0b4e7c2d5a9f1b3e6c0d4
JWT_REFRESH_EXPIRES_IN=30d

# Generate a secure secret:
# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Note: jwt.sign() accepts any JavaScript object as the payload. The values you include become claims in the token. Standard claims (iat โ€” issued at, exp โ€” expires at) are added automatically when you provide expiresIn. Custom claims you add (like id, role) are called private claims. Keep the payload small โ€” tokens are sent with every request and should not be bloated with large user objects.
Tip: Always use process.env.JWT_SECRET โ€” never a hardcoded string. Generate the secret with Node’s built-in crypto.randomBytes(64).toString('hex') to get a 128-character hex string. A short or guessable secret (like 'secret' or 'myapp') allows anyone to forge tokens by brute-forcing the HMAC signature. The secret should be at least 256 bits (64 hex characters) for HS256.
Warning: jwt.verify() throws synchronously โ€” it does not return a Promise. Always wrap it in a try/catch. The two most common errors it throws are TokenExpiredError (token is past its exp claim) and JsonWebTokenError (malformed token, wrong signature, or missing required fields). Both should result in a 401 Unauthorized response, but with different messages to help debugging.

The JWT Utility Module

// server/src/utils/jwt.js
const jwt = require('jsonwebtoken');

// โ”€โ”€ Sign an access token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const signAccessToken = (userId, role) =>
  jwt.sign(
    { id: userId, role },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
  );

// โ”€โ”€ Sign a refresh token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const signRefreshToken = (userId) =>
  jwt.sign(
    { id: userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d' }
  );

// โ”€โ”€ Verify an access token โ€” throws on invalid/expired โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const verifyAccessToken = (token) =>
  jwt.verify(token, process.env.JWT_SECRET);

// โ”€โ”€ Verify a refresh token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const verifyRefreshToken = (token) =>
  jwt.verify(token, process.env.JWT_REFRESH_SECRET);

module.exports = { signAccessToken, signRefreshToken, verifyAccessToken, verifyRefreshToken };

Using the JWT Utility in Auth Controllers

// server/src/controllers/authController.js
const User              = require('../models/User');
const AppError          = require('../utils/AppError');
const asyncHandler      = require('../utils/asyncHandler');
const { signAccessToken } = require('../utils/jwt');

// โ”€โ”€ Helper: sign token + send response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const sendTokenResponse = (res, statusCode, user, message) => {
  const token = signAccessToken(user._id, user.role);
  res.status(statusCode).json({
    success: true,
    message,
    token,
    data: {
      _id:    user._id,
      name:   user.name,
      email:  user.email,
      role:   user.role,
      avatar: user.avatar,
    },
  });
};

// @desc    Register
// @route   POST /api/auth/register
const register = asyncHandler(async (req, res) => {
  const { name, email, password } = req.body;
  const existing = await User.exists({ email });
  if (existing) throw new AppError('Email already registered', 409);
  const user = await User.create({ name, email, password });
  sendTokenResponse(res, 201, user, 'Account created successfully');
});

// @desc    Login
// @route   POST /api/auth/login
const login = asyncHandler(async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findOne({ email }).select('+password');
  if (!user || !(await user.comparePassword(password))) {
    throw new AppError('Invalid email or password', 401);
  }
  sendTokenResponse(res, 200, user, 'Logged in successfully');
});

// @desc    Get current user
// @route   GET /api/auth/me
const getMe = asyncHandler(async (req, res) => {
  // req.user attached by protect middleware
  res.json({ success: true, data: req.user });
});

module.exports = { register, login, getMe };

JWT Error Handling in errorHandler.js

// server/src/middleware/errorHandler.js (JWT-specific cases)

// โ”€โ”€ TokenExpiredError โ€” token is past its exp claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (err.name === 'TokenExpiredError') {
  statusCode    = 401;
  message       = 'Your session has expired โ€” please log in again';
  isOperational = true;
}

// โ”€โ”€ JsonWebTokenError โ€” malformed, wrong secret, or tampered โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (err.name === 'JsonWebTokenError') {
  statusCode    = 401;
  message       = 'Invalid token โ€” please log in again';
  isOperational = true;
}

// โ”€โ”€ NotBeforeError โ€” token used before its nbf claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (err.name === 'NotBeforeError') {
  statusCode    = 401;
  message       = 'Token not yet valid โ€” please try again';
  isOperational = true;
}

Verifying Tokens Manually (Debugging)

// Quick test in Node.js REPL or a test script
const jwt    = require('jsonwebtoken');
const secret = 'your-development-secret';

// Sign
const token = jwt.sign({ id: '64a1f2b3', role: 'user' }, secret, { expiresIn: '1h' });
console.log('Token:', token);

// Decode without verifying (for debugging)
const decoded = jwt.decode(token);
console.log('Decoded (without verification):', decoded);
// { id: '64a1f2b3', role: 'user', iat: 1710000000, exp: 1710003600 }

// Verify
try {
  const verified = jwt.verify(token, secret);
  console.log('Verified:', verified); // same as decoded if signature valid
} catch (err) {
  console.error('Error:', err.name, err.message);
  // TokenExpiredError: jwt expired
  // JsonWebTokenError: invalid signature
}

Common Mistakes

Mistake 1 โ€” Using jwt.decode() instead of jwt.verify() to authenticate

โŒ Wrong โ€” decode does not verify the signature:

const decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
req.user = await User.findById(decoded.id);
// Anyone can forge a token! decode() never checks the signature

โœ… Correct โ€” always use jwt.verify():

const decoded = jwt.verify(token, process.env.JWT_SECRET); // โœ“ checks signature

Mistake 2 โ€” Not awaiting asyncHandler โ€” forgetting that jwt.verify is sync

โŒ Wrong โ€” jwt.verify throws synchronously inside async handler:

const protect = async (req, res, next) => {
  const decoded = jwt.verify(token, secret); // throws sync โ€” not caught by async handler!
};

โœ… Correct โ€” catch sync throws with try/catch:

try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  req.user = await User.findById(decoded.id);
  next();
} catch (err) {
  next(err); // passes to global errorHandler โœ“
}

Mistake 3 โ€” Using the same secret for access and refresh tokens

โŒ Wrong โ€” if the secret is compromised, both token types are invalid:

const accessToken  = jwt.sign({ id }, process.env.JWT_SECRET);
const refreshToken = jwt.sign({ id }, process.env.JWT_SECRET); // same secret!

โœ… Correct โ€” separate secrets for access and refresh tokens:

const accessToken  = jwt.sign({ id }, process.env.JWT_SECRET);
const refreshToken = jwt.sign({ id }, process.env.JWT_REFRESH_SECRET); // โœ“

Quick Reference

Task Code
Sign a token jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '7d' })
Verify a token jwt.verify(token, process.env.JWT_SECRET) โ€” throws on invalid
Decode without verify jwt.decode(token) โ€” debugging only, NOT for auth
Handle expired token Catch TokenExpiredError โ†’ 401
Handle invalid token Catch JsonWebTokenError โ†’ 401
Generate a strong secret crypto.randomBytes(64).toString('hex')

🧠 Test Yourself

A user’s token expired 10 minutes ago. They make a request to GET /api/posts/new. Your protect middleware calls jwt.verify(token, secret). What happens and what should your API return?