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'))"
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.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.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') |