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/bcryptOne 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.
Related Articles
Express.js REST API Setup: Complete Guide with Error Handling
Learn how to set up a production-ready Express.js REST API with CORS and error handling.
Sequelize ORM with MySQL Setup: Complete Guide for Node.js
Complete guide with connection pooling, migrations, and best practices for database setup.
Sequelize Associations and Relationships: Complete Guide
Learn how to define and use Sequelize associations in Node.js with examples.
Multer File Upload in Express.js: Complete Guide with Examples
Learn how to implement file uploads in Express.js using Multer with validation.