DEV Community

Cover image for JWT Authentication in Express.js and Node.js: Complete Guide
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

JWT Authentication in Express.js and Node.js: Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

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,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Important:

  • Generate strong, random secrets using openssl rand -base64 32
  • Never commit your .env file to version control
  • Use different secrets for development and production

Security Best Practices

  1. Always use a strong, unique JWT_SECRET - Store in environment variables, never in code
  2. Set appropriate token expiration times - 7 days for most applications, shorter for sensitive operations
  3. Never store sensitive data in JWT tokens - Tokens can be decoded (but not modified)
  4. Always hash passwords with bcrypt - Minimum 10 rounds for good security
  5. Implement token refresh mechanisms - For better security and user experience
  6. Validate user status on every request - Check if user is active, not just if token is valid
  7. Use HTTP-only cookies for refresh tokens - More secure than localStorage
  8. Implement rate limiting - Prevent brute force attacks
  9. Use generic error messages - Prevent user enumeration attacks
  10. 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",
});
Enter fullscreen mode Exit fullscreen mode

Option 2: Authorization Header

// Client sends: Authorization: Bearer <token>
// Server reads: req.headers.authorization
Enter fullscreen mode Exit fullscreen mode

Option 3: localStorage (Less Secure)

// Client stores token in localStorage
// Client sends in Authorization header
// Not recommended for production
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

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)