Back to Blog
Node.js13 min read

JWT Authentication in Express.js – Complete Guide

Learn JWT authentication in Express.js and Node.js. Complete guide with middleware, token generation, bcrypt password hashing, and protected routes. Step-by-step tutorial.

Authentication is one of those topics that seems simple until you actually have to implement it securely. I've seen too many applications with authentication vulnerabilities—from storing plain text passwords to improperly validating tokens. When I first built a JWT authentication system, I made plenty of mistakes, but those mistakes taught me what actually matters in production.

JSON Web Tokens have become the standard for stateless authentication in REST APIs, and for good reason. They're compact, can be verified without database lookups, and work perfectly with microservices architectures. But implementing them correctly requires understanding not just how to generate tokens, but also how to secure them, handle expiration, and protect your routes properly.

In this guide, I'll walk you through building a complete authentication system from scratch. We'll cover user registration with proper password hashing (using bcrypt), secure login flows, JWT token generation and verification, and middleware for protecting routes. I'll also share security best practices I've learned from deploying these systems in production.

Setting Up Your Dependencies

Before we start coding, let's get our dependencies installed. You'll need jsonwebtoken for creating and verifying tokens, bcrypt for password hashing (never store passwords in plain text!), and express for our server. If you're using TypeScript, the type definitions are essential for catching errors early.

npm install jsonwebtoken bcrypt express npm install --save-dev @types/jsonwebtoken @types/bcrypt

One thing to note: bcrypt is a CPU-intensive operation by design (that's what makes it secure), so don't be surprised if password hashing takes a few hundred milliseconds. This is intentional—it makes brute force attacks much harder. I typically use 10 rounds, which provides a good balance between security and performance for most applications.

User Registration with Password Hashing

Here's how to implement user registration with bcrypt password hashing:

const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); const { User } = require("../models/index"); class AuthController { async register(req, res) { try { const { name, email, password, role = "staff" } = req.body; // Validate input if (!name || !email || !password) { return res.status(400).json({ success: false, message: "Name, email, and password are required", }); } // Check if user already exists const existingUser = await User.findOne({ where: { email } }); if (existingUser) { return res.status(409).json({ success: false, message: "User with this email already exists", }); } // Hash password with bcrypt (10 rounds) const hashedPassword = await bcrypt.hash(password, 10); // Create user const user = await User.create({ name, email, password: hashedPassword, role, status: "active", }); // Generate JWT token const token = jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET || "your-secret-key-change-this", { expiresIn: "7d" } ); return res.status(201).json({ success: true, message: "User registered successfully", data: { id: user.id, name: user.name, email: user.email, role: user.role, token, }, }); } catch (error) { console.error("Error registering user:", error); return res.status(500).json({ success: false, message: "Error registering user", error: error.message, }); } } }

User Login with Enhanced Security

Login is where security really matters. I've added rate limiting, login attempt tracking, and better error handling. Here's a production-ready login implementation:

async login(req, res) { try { const { email, password } = req.body; // Validate input if (!email || !password) { return res.status(400).json({ success: false, message: "Email and password are required", }); } // Basic email format validation const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ success: false, message: "Please provide a valid email address", }); } // Find user (case-insensitive email search) const user = await User.findOne({ where: { email: email.toLowerCase().trim() } }); // Always return the same error message to prevent user enumeration if (!user) { return res.status(401).json({ success: false, message: "Invalid email or password", }); } // Check if user account is active if (user.status !== "active") { return res.status(403).json({ success: false, message: "Your account has been deactivated. Please contact support.", }); } // Check for too many failed login attempts (optional security feature) if (user.failedLoginAttempts >= 5) { const lockoutTime = new Date(user.lastFailedLoginAttempt); lockoutTime.setMinutes(lockoutTime.getMinutes() + 15); if (new Date() < lockoutTime) { return res.status(429).json({ success: false, message: "Too many failed login attempts. Please try again later.", }); } else { // Reset failed attempts after lockout period await user.update({ failedLoginAttempts: 0, lastFailedLoginAttempt: null }); } } // Verify password with bcrypt const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { // Increment failed login attempts await user.update({ failedLoginAttempts: user.failedLoginAttempts + 1, lastFailedLoginAttempt: new Date(), }); return res.status(401).json({ success: false, message: "Invalid email or password", }); } // Reset failed login attempts on successful login await user.update({ failedLoginAttempts: 0, lastFailedLoginAttempt: null, lastLogin: new Date() }); // Generate JWT token with user information const tokenPayload = { id: user.id, email: user.email, role: user.role, name: user.name, }; const token = jwt.sign( tokenPayload, process.env.JWT_SECRET || "your-secret-key-change-this", { expiresIn: process.env.JWT_EXPIRES_IN || "7d", issuer: "your-app-name", audience: "your-app-users", } ); // Optionally generate refresh token for better security const refreshToken = jwt.sign( { id: user.id, type: "refresh" }, process.env.JWT_REFRESH_SECRET || "your-refresh-secret-key", { expiresIn: "30d" } ); // Set HTTP-only cookie for refresh token (more secure than localStorage) res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", // Only send over HTTPS in production sameSite: "strict", maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }); return res.status(200).json({ success: true, message: "Login successful", data: { id: user.id, name: user.name, email: user.email, role: user.role, token, // Access token expiresIn: process.env.JWT_EXPIRES_IN || "7d", }, }); } catch (error) { console.error("Error logging in:", error); return res.status(500).json({ success: false, message: "An error occurred during login. Please try again later.", // Don't expose error details in production ...(process.env.NODE_ENV === "development" && { error: error.message }), }); } }

This enhanced login includes several security improvements: rate limiting to prevent brute force attacks, failed login attempt tracking, refresh tokens stored in HTTP-only cookies (more secure than localStorage), and generic error messages to prevent user enumeration. The key security principle here is to never reveal whether an email exists in your system—always return the same error message.

Enhanced JWT Authentication Middleware

Middleware is where you validate tokens on every request. I've enhanced this with user status checking, token refresh support, and better error handling. This is the pattern I use in production:

const jwt = require("jsonwebtoken"); const { User } = require("../models/index"); const verifyToken = async (req, res, next) => { try { // Get token from Authorization header const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).json({ success: false, message: "No token provided. Please login to access this resource.", }); } const token = authHeader.substring(7); // Remove 'Bearer ' prefix // Verify token let decoded; try { decoded = jwt.verify( token, process.env.JWT_SECRET || "your-secret-key-change-this" ); } catch (verifyError) { if (verifyError.name === "TokenExpiredError") { // Try to refresh token if refresh token is available const refreshToken = req.cookies?.refreshToken; if (refreshToken) { try { const refreshDecoded = jwt.verify( refreshToken, process.env.JWT_REFRESH_SECRET || "your-refresh-secret-key" ); // Generate new access token const user = await User.findByPk(refreshDecoded.id); if (!user || user.status !== "active") { return res.status(401).json({ success: false, message: "User account is inactive. Please login again.", }); } const newToken = jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET || "your-secret-key-change-this", { expiresIn: process.env.JWT_EXPIRES_IN || "7d" } ); // Set new token in response header res.setHeader("X-New-Token", newToken); decoded = { id: user.id, email: user.email, role: user.role }; } catch (refreshError) { return res.status(401).json({ success: false, message: "Token has expired. Please login again.", }); } } else { return res.status(401).json({ success: false, message: "Token has expired. Please login again.", }); } } else if (verifyError.name === "JsonWebTokenError") { return res.status(401).json({ success: false, message: "Invalid token. Please login again.", }); } else { throw verifyError; } } // Verify user still exists and is active const user = await User.findByPk(decoded.id); if (!user) { return res.status(401).json({ success: false, message: "User not found. Please login again.", }); } if (user.status !== "active") { return res.status(403).json({ success: false, message: "Your account has been deactivated.", }); } // Add user info to request object req.user = { id: user.id, email: user.email, role: user.role, name: user.name, }; next(); } catch (error) { console.error("Error verifying token:", error); return res.status(500).json({ success: false, message: "Error verifying token", ...(process.env.NODE_ENV === "development" && { error: error.message }), }); } }; // Optional: Middleware to attach user without requiring authentication const optionalAuth = async (req, res, next) => { try { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith("Bearer ")) { const token = authHeader.substring(7); try { const decoded = jwt.verify( token, process.env.JWT_SECRET || "your-secret-key-change-this" ); const user = await User.findByPk(decoded.id); if (user && user.status === "active") { req.user = { id: user.id, email: user.email, role: user.role, name: user.name, }; } } catch (error) { // Ignore errors for optional auth } } next(); } catch (error) { // Continue even if optional auth fails next(); } }; module.exports = { verifyToken, optionalAuth };

This enhanced middleware includes automatic token refresh (if a refresh token is available), user status verification on every request, and an optional authentication middleware for routes that work with or without authentication. The key security improvement is checking that the user account is still active on every request— this prevents deactivated users from accessing protected resources even if they have a valid token.

Role-Based Access Control

Adding role-based authorization middleware:

const checkRole = (...allowedRoles) => { return (req, res, next) => { if (!req.user) { return res.status(401).json({ success: false, message: "Authentication required", }); } if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ success: false, message: "You do not have permission to access this resource", }); } next(); }; }; module.exports = { verifyToken, checkRole };

Using Middleware in Routes

const express = require("express"); const router = express.Router(); const productController = require("../controllers/productController"); const { verifyToken, checkRole } = require("../middleware/auth.middleware"); // All product routes require authentication router.use(verifyToken); // Only admin can create products router.post("/", checkRole("admin"), productController.create); // All authenticated users can view products router.get("/", productController.getAll); router.get("/:id", productController.getById); // Only admin can update/delete router.put("/:id", checkRole("admin"), productController.update); router.delete("/:id", checkRole("admin"), productController.delete); module.exports = router;

Best Practices

  • Always use a strong, unique JWT_SECRET stored in environment variables
  • Set appropriate token expiration times (7 days for most applications)
  • Never store sensitive data in JWT tokens
  • Always hash passwords with bcrypt (minimum 10 rounds)
  • Implement token refresh mechanisms for better security
  • Validate user status (active/inactive) on every request

Final Thoughts: Security in Production

Building authentication is one thing; securing it for production is another. Over the years, I've learned that the difference between a secure system and a vulnerable one often comes down to the details. Here are the most important things to remember:

First, never commit your JWT_SECRET to version control. Use environment variables, and make sure your production secret is strong and unique. I've seen applications compromised because developers used weak secrets or shared them in code repositories. Consider using a secrets management service for production.

Second, always validate tokens on every request. Don't assume that because a token was valid yesterday, it's still valid today. Users can be deactivated, roles can change, and tokens can expire. Your middleware should check all of these things.

Finally, implement proper error handling. Don't leak information about whether an email exists in your system. Use generic error messages like "Invalid email or password" instead of "Email not found" or "Incorrect password." This makes it harder for attackers to enumerate valid user accounts.

JWT authentication, when implemented correctly, provides a stateless, scalable solution that works well across microservices. Combined with bcrypt for password hashing and role-based access control, you have a solid foundation for securing your REST APIs. Just remember: security is an ongoing process, not a one-time setup.