JSON Web Tokens (JWT) are a popular method for implementing authentication in REST APIs. In this guide, we'll implement a complete JWT authentication system using Express.js, including user registration, login, password hashing with bcrypt, and protected routes.
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.
π Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is JWT Authentication?
JWT (JSON Web Token) is a compact, URL-safe token format used for securely transmitting information between parties. In authentication:
- Stateless - No need to store sessions on the server
- Scalable - Works across microservices
- Self-contained - Token includes user information
- Secure - Signed with a secret key
- Expirable - Tokens can have expiration times
Installation
Before we start coding, let's get our dependencies installed:
npm install jsonwebtoken bcrypt express
npm install --save-dev @types/jsonwebtoken @types/bcrypt
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.
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. Here's a production-ready login implementation with rate limiting, login attempt tracking, and better error handling:
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
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 }),
});
}
}
Security Features in Login
- Rate limiting - Prevents brute force attacks
- Failed login tracking - Locks accounts after multiple failed attempts
- Generic error messages - Prevents user enumeration
- Refresh tokens - Stored in HTTP-only cookies for better security
- Account status checking - Verifies user is active
JWT Authentication Middleware
Middleware is where you validate tokens on every request. Here's an enhanced middleware with user status checking, token refresh support, and better error handling:
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 };
Middleware Features
- Automatic token refresh - Refreshes expired tokens if refresh token is available
- User status verification - Checks if user is active on every request
- Optional authentication - For routes that work with or without auth
- Error handling - Proper error messages for different scenarios
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
Here's how to use the middleware in your Express 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;
Environment Variables
Configure your .env file:
JWT_SECRET=your-strong-secret-key-here
JWT_REFRESH_SECRET=your-refresh-secret-key-here
JWT_EXPIRES_IN=7d
NODE_ENV=production
Important:
- Generate strong, random secrets using
openssl rand -base64 32 - Never commit your
.envfile to version control - Use different secrets for development and production
Security Best Practices
- Always use a strong, unique JWT_SECRET - Store in environment variables, never in code
- Set appropriate token expiration times - 7 days for most applications, shorter for sensitive operations
- Never store sensitive data in JWT tokens - Tokens can be decoded (but not modified)
- Always hash passwords with bcrypt - Minimum 10 rounds for good security
- Implement token refresh mechanisms - For better security and user experience
- Validate user status on every request - Check if user is active, not just if token is valid
- Use HTTP-only cookies for refresh tokens - More secure than localStorage
- Implement rate limiting - Prevent brute force attacks
- Use generic error messages - Prevent user enumeration attacks
- Enable HTTPS in production - Essential for secure token transmission
Common Patterns
Token Storage Options
Option 1: HTTP-only Cookies (Recommended)
res.cookie("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});
Option 2: Authorization Header
// Client sends: Authorization: Bearer <token>
// Server reads: req.headers.authorization
Option 3: localStorage (Less Secure)
// Client stores token in localStorage
// Client sends in Authorization header
// Not recommended for production
Handling Token Expiration
// Client-side: Check token expiration before making requests
const isTokenExpired = (token) => {
try {
const decoded = jwt.decode(token);
return decoded.exp < Date.now() / 1000;
} catch {
return true;
}
};
// Server-side: Middleware handles expiration automatically
// Returns 401 if expired, tries refresh token if available
Resources and Further Reading
- π Full JWT Authentication Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- Express.js REST API Setup - Learn how to set up a production-ready Express.js REST API
- Sequelize ORM with MySQL Setup - Database setup for user storage
- JWT.io - Debug and decode JWT tokens
- jsonwebtoken Documentation - Official jsonwebtoken library docs
- bcrypt Documentation - bcrypt password hashing library
- Better Auth Guide - Alternative authentication solution
Conclusion
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.
Key Takeaways:
- JWT tokens provide stateless authentication
- bcrypt hashing secures passwords (never store plain text)
- Middleware validates tokens on every request
- Role-based access control restricts resources by user role
- Token refresh improves security and user experience
- Rate limiting prevents brute force attacks
- Generic error messages prevent user enumeration
- HTTP-only cookies are more secure than localStorage
Building authentication is one thing; securing it for production is another. The difference between a secure system and a vulnerable one often comes down to the details. Always validate tokens on every request, use strong secrets, implement proper error handling, and never commit secrets to version control.
What's your experience with JWT authentication? Share your tips and tricks in the comments below! π
π‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on Node.js development and authentication best practices.
Top comments (0)