nodejs-jwt-token-authentication

Node.js JWT Authentication: The Complete Developer’s Guide 2025

Build secure JWT authentication in Node.js step by step with production-ready security features. This comprehensive Node.js JWT authentication tutorial covers everything from basic setup to advanced security implementations using Node.js JWT authentication with MySQL bcryptjs.

Why Choose JWT for Node.js Authentication?

Node.js JWT authentication has become the industry standard for modern web applications. Unlike traditional session-based authentication, JWT tokens are stateless, making them perfect for distributed systems and microservices architectures.

  • Key benefits of secure JWT authentication Node.js step by step implementation:
  • Performance: Local token verification without database hits
  • Stateless authentication: No server-side session storage required
  • Scalability: Works seamlessly across multiple servers
  • Cross-platform compatibility: Perfect for web, mobile, and API authentication
  • Security: Cryptographically signed tokens prevent tampering

Think of a JWT token like a special ID card. It has your information encoded inside it, and it’s signed by the server so nobody can fake it. When you send this token with your requests, the server can instantly verify who you are without looking anything up in a database.

Project Setup and Dependencies

Let’s start by creating our project folder:

mkdir jwt-auth-app
cd jwt-auth-app
npm init -y

Now we need to install the packages:

npm install express bcryptjs jsonwebtoken dotenv mysql2 cors express-rate-limit nodemailer helmet joi
npm install --save-dev nodemon jest supertest

Each package serves a specific purpose:

  • express – Web framework for building the API
  • bcryptjs – Secure password hashing
  • jsonwebtoken – JWT token creation and verification
  • dotenv – Environment variable management
  • mysql2 – MySQL database driver
  • cors – Cross-Origin Resource Sharing configuration
  • express-rate-limit – Rate limiting middleware
  • nodemailer – Email sending functionality
  • helmet – Security headers middleware
  • joi – Input validation
  • nodemon – Development server auto-restart
  • jest – Testing framework
  • supertest – HTTP assertion testing

Create the project structure:

jwt-auth-app/
├── server.js
├── .env
├── config/
│   ├── database.js
│   └── email.js
├── models/
│   └── User.js
├── middleware/
│   ├── auth.js
│   ├── rateLimiter.js
│   └── validation.js
├── routes/
│   └── auth.js
├── utils/
│   ├── tokenBlacklist.js
│   └── emailTemplates.js
├── tests/
│   └── auth.test.js
└── package.json

Environment Configuration

Create a comprehensive .env file for our production ready JWT authentication:

PORT=3000
JWT_SECRET=your-super-secret-jwt-key-make-it-long-and-random
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
NODE_ENV=development

# Database configuration
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-database-password
DB_NAME=jwt_auth_db

# Email configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
FROM_EMAIL=noreply@yourapp.com

# Frontend URL
FRONTEND_URL=http://localhost:3000

# CORS allowed origins (comma-separated)
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001

The JWT secret should be a long, random string. Generate one using openssl rand -base64 64 for production environments.

We set JWT tokens to expire in 15 minutes. Meaning if someone steals a token, they can only use it for 15 minutes. We’ll implement token refresh so users don’t have to log in constantly.

Express Server Foundation

Set up the basic Express server:

const express = require('express');
const dotenv = require('dotenv');

dotenv.config();

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.json({ message: 'JWT Auth API is running' });
});

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

This creates a basic Express server with JSON parsing middleware and route configuration.

Database Setup and Configuration

First, create the MySQL database and user table. Connect to MySQL and run these commands:

CREATE DATABASE jwt_auth_db;
USE jwt_auth_db;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  password VARCHAR(255) NOT NULL,
  name VARCHAR(255) NOT NULL,
  email_verified BOOLEAN DEFAULT FALSE,
  email_verification_token VARCHAR(255) DEFAULT NULL,
  email_verification_expires DATETIME DEFAULT NULL,
  password_reset_token VARCHAR(255) DEFAULT NULL,
  password_reset_expires DATETIME DEFAULT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE token_blacklist (
  id INT AUTO_INCREMENT PRIMARY KEY,
  token_jti VARCHAR(255) UNIQUE NOT NULL,
  user_id INT NOT NULL,
  expires_at DATETIME NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- Add indexes for better performance
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_email_verification_token ON users(email_verification_token);
CREATE INDEX idx_users_password_reset_token ON users(password_reset_token);
CREATE INDEX idx_token_blacklist_jti ON token_blacklist(token_jti);
CREATE INDEX idx_token_blacklist_expires ON token_blacklist(expires_at);

The token_blacklist is use for handling logout and token revocation. When someone logs out or changes their password, we add their token to this blacklist so it can’t be used anymore.

Create a database connection configuration:

const mysql = require('mysql2');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

const promisePool = pool.promise();

module.exports = promisePool;

The connection pool manages multiple database connections efficiently and the promise wrapper allows us to use async/await syntax.

JWT Authentication with Email Verification

Here’s how JWT authentication works with email verification in our system:

  • Verification Email: We send an email with a secure verification token
  • Token Verification: User clicks the link, we verify the token and set email_verified = true
const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: process.env.SMTP_PORT,
  secure: false,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS
  }
});

const sendEmail = async (to, subject, html) => {
  try {
    const mailOptions = {
      from: process.env.FROM_EMAIL,
      to,
      subject,
      html
    };

    const result = await transporter.sendMail(mailOptions);
    console.log('Email sent successfully:', result.messageId);
    return result;
  } catch (error) {
    console.error('Email sending failed:', error);
    throw error;
  }
};

module.exports = { sendEmail };

Email functionality is essential for as it enables password reset and account verification features.

For Gmail, we’ll need to use an “App Password” instead of our regular password. Go to your Google Account settings, enable 2-factor authentication, then create an app password specifically for this application.

Email Templates

Create reusable email templates:

const getEmailVerificationTemplate = (verificationUrl, name) => {
  return `
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Verify Your Email</title>
        <style>
            body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
            .button { background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; }
        </style>
    </head>
    <body>
        <div class="container">
            <h2>Welcome ${name}!</h2>
            <p>Thank you for registering. Please click the button below to verify your email address:</p>
            <a href="${verificationUrl}" class="button">Verify Email</a>
            <p>If the button doesn't work, copy and paste this link into your browser:</p>
            <p>${verificationUrl}</p>
            <p>This link will expire in 24 hours.</p>
            <p>If you didn't create an account, please ignore this email.</p>
        </div>
    </body>
    </html>
  `;
};

const getPasswordResetTemplate = (resetUrl, name) => {
  return `
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Password Reset</title>
        <style>
            body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
            .button { background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; }
        </style>
    </head>
    <body>
        <div class="container">
            <h2>Password Reset Request</h2>
            <p>Hi ${name},</p>
            <p>You requested a password reset. Click the button below to reset your password:</p>
            <a href="${resetUrl}" class="button">Reset Password</a>
            <p>If the button doesn't work, copy and paste this link into your browser:</p>
            <p>${resetUrl}</p>
            <p>This link will expire in 1 hour.</p>
            <p>If you didn't request this reset, please ignore this email and your password will remain unchanged.</p>
        </div>
    </body>
    </html>
  `;
};

module.exports = {
  getEmailVerificationTemplate,
  getPasswordResetTemplate
};
Node.js JWT Authentication with Email Verification

JWT Token Blacklist Implementation

One challenge with JWT tokens is that they can’t be revoked once issued. So, we can implement a blacklist:

const db = require('../config/database');

class TokenBlacklist {
  static async addToBlacklist(tokenJti, userId, expiresAt) {
    try {
      await db.execute(
        'INSERT INTO token_blacklist (token_jti, user_id, expires_at) VALUES (?, ?, ?)',
        [tokenJti, userId, expiresAt]
      );
    } catch (error) {
      throw error;
    }
  }

  static async isBlacklisted(tokenJti) {
    try {
      const [rows] = await db.execute(
        'SELECT id FROM token_blacklist WHERE token_jti = ? AND expires_at > NOW()',
        [tokenJti]
      );
      return rows.length > 0;
    } catch (error) {
      throw error;
    }
  }

  static async cleanupExpiredTokens() {
    try {
      await db.execute('DELETE FROM token_blacklist WHERE expires_at <= NOW()');
    } catch (error) {
      console.error('Failed to cleanup expired tokens:', error);
    }
  }

  static async revokeAllUserTokens(userId) {
    try {
      // In a production environment, you'd want to track all active tokens
      // For now, we'll just mark this timestamp and check it in middleware
      await db.execute(
        'UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?',
        [userId]
      );
    } catch (error) {
      throw error;
    }
  }
}

setInterval(() => {
  TokenBlacklist.cleanupExpiredTokens();
}, 60 * 60 * 1000);

module.exports = TokenBlacklist;

The blacklist works by storing the JTI (JWT ID) of revoked tokens. Every JWT we create gets a unique JTI, so we can blacklist specific tokens without affecting others. The cleanup function runs every hour to remove expired tokens and keep the database lean.

The “revoke all tokens” function is clever – instead of tracking every token, we just update the user’s timestamp. Then in our authentication middleware, we check if the token was issued before this timestamp.

Input Validation Middleware

Create robust input validation:

const Joi = require('joi');

const validateRegistration = (req, res, next) => {
  const schema = Joi.object({
    email: Joi.string().email().required().messages({
      'string.email': 'Please provide a valid email address',
      'any.required': 'Email is required'
    }),
    password: Joi.string()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
      .required()
      .messages({
        'string.min': 'Password must be at least 8 characters long',
        'string.pattern.base': 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
        'any.required': 'Password is required'
      }),
    name: Joi.string().min(2).max(50).required().messages({
      'string.min': 'Name must be at least 2 characters long',
      'string.max': 'Name must not exceed 50 characters',
      'any.required': 'Name is required'
    })
  });

  const { error } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({
      success: false,
      message: error.details[0].message
    });
  }

  next();
};

const validateLogin = (req, res, next) => {
  const schema = Joi.object({
    email: Joi.string().email().required(),
    password: Joi.string().required()
  });

  const { error } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({
      success: false,
      message: 'Please provide valid email and password'
    });
  }

  next();
};

const validatePasswordReset = (req, res, next) => {
  const schema = Joi.object({
    password: Joi.string()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
      .required()
      .messages({
        'string.min': 'Password must be at least 8 characters long',
        'string.pattern.base': 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
        'any.required': 'Password is required'
      }),
    token: Joi.string().required()
  });

  const { error } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({
      success: false,
      message: error.details[0].message
    });
  }

  next();
};

module.exports = {
  validateRegistration,
  validateLogin,
  validatePasswordReset
};

The password validation is particularly important. We require at least 8 characters with a mix of uppercase, lowercase, numbers, and special characters. This prevents users from setting weak passwords like “password123”.

Joi makes validation really clean and readable. The custom error messages help users understand exactly what they need to fix, rather than showing cryptic validation errors.

Rate Limiting Middleware

Implement comprehensive rate limiting:

const rateLimit = require('express-rate-limit');

const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: {
    success: false,
    message: 'Too many requests from this IP, please try again later'
  },
  standardHeaders: true,
  legacyHeaders: false
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, 
  message: {
    success: false,
    message: 'Too many authentication attempts, please try again in 15 minutes'
  },
  standardHeaders: true,
  legacyHeaders: false,
  skipSuccessfulRequests: true
});

const passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 3,
  message: {
    success: false,
    message: 'Too many password reset attempts, please try again in 1 hour'
  },
  standardHeaders: true,
  legacyHeaders: false
});

const emailVerificationLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 3,
  message: {
    success: false,
    message: 'Too many verification email requests, please try again in 1 hour'
  },
  standardHeaders: true,
  legacyHeaders: false
});

module.exports = {
  generalLimiter,
  authLimiter,
  passwordResetLimiter,
  emailVerificationLimiter
};

The rate limiting strategy balances security with user experience. General API calls allow 100 requests per 15 minutes for normal usage.

Authentication attempts are limited to 5 per 15 minutes to prevent brute force attacks while allowing legitimate users to retry.

Password reset attempts are restricted to 3 per hour to prevent abuse and spam. The skipSuccessfulRequests option means only failed login attempts count toward the limit, preventing legitimate users from being locked out.

User Model Implementation

Now create the User model with MySQL integration:

const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const db = require('../config/database');

class User {
  constructor(userData) {
    this.id = userData.id;
    this.email = userData.email;
    this.password = userData.password;
    this.name = userData.name;
    this.email_verified = userData.email_verified;
    this.email_verification_token = userData.email_verification_token;
    this.email_verification_expires = userData.email_verification_expires;
    this.password_reset_token = userData.password_reset_token;
    this.password_reset_expires = userData.password_reset_expires;
    this.created_at = userData.created_at;
    this.updated_at = userData.updated_at;
  }

  static async create(userData) {
    const { email, password, name } = userData;
    
    try {
      const [existingUser] = await db.execute(
        'SELECT id FROM users WHERE email = ?',
        [email]
      );

      if (existingUser.length > 0) {
        throw new Error('User already exists');
      }

      const saltRounds = 12;
      const hashedPassword = await bcrypt.hash(password, saltRounds);
 
      const verificationToken = crypto.randomBytes(32).toString('hex');
      const verificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);

      const [result] = await db.execute(
        `INSERT INTO users (email, password, name, email_verification_token, email_verification_expires) 
         VALUES (?, ?, ?, ?, ?)`,
        [email, hashedPassword, name, verificationToken, verificationExpires]
      );

      const [newUser] = await db.execute(
        'SELECT * FROM users WHERE id = ?',
        [result.insertId]
      );

      return new User(newUser[0]);
    } catch (error) {
      throw error;
    }
  }

  static async findByEmail(email) {
    try {
      const [users] = await db.execute(
        'SELECT * FROM users WHERE email = ?',
        [email]
      );

      if (users.length === 0) return null;
      return new User(users[0]);
    } catch (error) {
      throw error;
    }
  }

  static async findById(id) {
    try {
      const [users] = await db.execute(
        'SELECT * FROM users WHERE id = ?',
        [id]
      );

      if (users.length === 0) return null;
      return new User(users[0]);
    } catch (error) {
      throw error;
    }
  }

  static async findByVerificationToken(token) {
    try {
      const [users] = await db.execute(
        'SELECT * FROM users WHERE email_verification_token = ? AND email_verification_expires > NOW()',
        [token]
      );

      if (users.length === 0) return null;
      return new User(users[0]);
    } catch (error) {
      throw error;
    }
  }

  static async findByPasswordResetToken(token) {
    try {
      const [users] = await db.execute(
        'SELECT * FROM users WHERE password_reset_token = ? AND password_reset_expires > NOW()',
        [token]
      );

      if (users.length === 0) return null;
      return new User(users[0]);
    } catch (error) {
      throw error;
    }
  }

  async validatePassword(password) {
    return await bcrypt.compare(password, this.password);
  }

  async verifyEmail() {
    try {
      await db.execute(
        'UPDATE users SET email_verified = TRUE, email_verification_token = NULL, email_verification_expires = NULL WHERE id = ?',
        [this.id]
      );
      this.email_verified = true;
      this.email_verification_token = null;
      this.email_verification_expires = null;
    } catch (error) {
      throw error;
    }
  }

  async generatePasswordResetToken() {
    try {
      const resetToken = crypto.randomBytes(32).toString('hex');
      const resetExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

      await db.execute(
        'UPDATE users SET password_reset_token = ?, password_reset_expires = ? WHERE id = ?',
        [resetToken, resetExpires, this.id]
      );

      this.password_reset_token = resetToken;
      this.password_reset_expires = resetExpires;
      
      return resetToken;
    } catch (error) {
      throw error;
    }
  }

  async resetPassword(newPassword) {
    try {
      const saltRounds = 12;
      const hashedPassword = await bcrypt.hash(newPassword, saltRounds);

      await db.execute(
        'UPDATE users SET password = ?, password_reset_token = NULL, password_reset_expires = NULL WHERE id = ?',
        [hashedPassword, this.id]
      );

      this.password = hashedPassword;
      this.password_reset_token = null;
      this.password_reset_expires = null;
    } catch (error) {
      throw error;
    }
  }

  async regenerateEmailVerificationToken() {
    try {
      const verificationToken = crypto.randomBytes(32).toString('hex');
      const verificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); 

      await db.execute(
        'UPDATE users SET email_verification_token = ?, email_verification_expires = ? WHERE id = ?',
        [verificationToken, verificationExpires, this.id]
      );

      this.email_verification_token = verificationToken;
      this.email_verification_expires = verificationExpires;
      
      return verificationToken;
    } catch (error) {
      throw error;
    }
  }

  async updateProfile(name) {
    try {
      await db.execute(
        'UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
        [name, this.id]
      );
      this.name = name;
      return this;
    } catch (error) {
      throw error;
    }
  }

  toJSON() {
    const { password, email_verification_token, password_reset_token, ...userWithoutSensitiveData } = this;
    return userWithoutSensitiveData;
  }
}

module.exports = User;

The password hashing uses bcrypt with 12 salt rounds, which provides strong security without significantly impacting performance.

The email verification tokens use crypto.randomBytes(32) which generates cryptographically secure random tokens that are nearly impossible to guess.

The password validation method uses bcrypt.compare() which prevents timing attacks by taking consistent time regardless of password correctness.

JWT Authentication Middleware

Create middleware to protect routes and verify JWT tokens:

const jwt = require('jsonwebtoken');
const User = require('../models/User');
const TokenBlacklist = require('../utils/tokenBlacklist');

const authenticateToken = async (req, res, next) => {
  try {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
      return res.status(401).json({
        success: false,
        message: 'Access token is missing'
      });
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    if (decoded.jti && await TokenBlacklist.isBlacklisted(decoded.jti)) {
      return res.status(401).json({
        success: false,
        message: 'Token has been revoked'
      });
    }
    
    const user = await User.findById(decoded.userId);
    if (!user) {
      return res.status(401).json({
        success: false,
        message: 'Invalid token - user not found'
      });
    }

    const tokenIssuedAt = new Date(decoded.iat * 1000);
    const userUpdatedAt = new Date(user.updated_at);
    
    if (userUpdatedAt > tokenIssuedAt) {
      return res.status(401).json({
        success: false,
        message: 'Token has been revoked'
      });
    }

    req.user = user;
    req.token = token;
    req.tokenDecoded = decoded;
    next();

  } catch (error) {
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        success: false,
        message: 'Invalid token'
      });
    }

    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        message: 'Token has expired'
      });
    }

    res.status(500).json({
      success: false,
      message: 'Token verification failed'
    });
  }
};

const requireEmailVerification = (req, res, next) => {
  if (!req.user.email_verified) {
    return res.status(403).json({
      success: false,
      message: 'Please verify your email address before accessing this resource'
    });
  }
  next();
};

module.exports = { 
  authenticateToken, 
  requireEmailVerification 
};

This middleware implements a multi-layered token validation system. First, it extracts the Bearer token from the Authorization header using the standard “Bearer ” format. The token blacklisting check prevents revoked tokens from being used even if they haven’t expired yet.

The user timestamp comparison (userUpdatedAt > tokenIssuedAt) automatically invalidates all tokens when a user changes their password or account is compromised. This approach provides better security than storing individual tokens in a database while maintaining performance.

Complete Protected Routes Creation

Implement routes that require authentication:

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const User = require('../models/User');
const { sendEmail } = require('../config/email');
const { getEmailVerificationTemplate, getPasswordResetTemplate } = require('../utils/emailTemplates');
const { authenticateToken, requireEmailVerification } = require('../middleware/auth');
const { validateRegistration, validateLogin, validatePasswordReset } = require('../middleware/validation');
const { authLimiter, passwordResetLimiter, emailVerificationLimiter } = require('../middleware/rateLimiter');
const TokenBlacklist = require('../utils/tokenBlacklist');

const router = express.Router();

const generateToken = (userId, expiresIn = process.env.JWT_EXPIRES_IN) => {
  const jti = crypto.randomBytes(16).toString('hex'); 
  
  return {
    token: jwt.sign(
      { userId, jti },
      process.env.JWT_SECRET,
      { expiresIn }
    ),
    jti
  };
};

router.post('/register', authLimiter, validateRegistration, async (req, res) => {
  try {
    const { email, password, name } = req.body;
    const user = await User.create({ email, password, name });
    const verificationUrl = `${process.env.FRONTEND_URL}/verify-email/${user.email_verification_token}`;
    const emailHtml = getEmailVerificationTemplate(verificationUrl, user.name);
    
    await sendEmail(
      user.email,
      'Verify Your Email Address',
      emailHtml
    );

    res.status(201).json({
      success: true,
      message: 'User created successfully. Please check your email for verification instructions.',
      user: user.toJSON()
    });

  } catch (error) {
    if (error.message === 'User already exists') {
      return res.status(400).json({
        success: false,
        message: error.message
      });
    }

    console.error('Registration error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during registration'
    });
  }
});

router.get('/verify-email/:token', async (req, res) => {
  try {
    const { token } = req.params;
    
    const user = await User.findByVerificationToken(token);
    if (!user) {
      return res.status(400).json({
        success: false,
        message: 'Invalid or expired verification token'
      });
    }

    await user.verifyEmail();
    const { token: jwtToken } = generateToken(user.id);

    res.json({
      success: true,
      message: 'Email verified successfully',
      token: jwtToken,
      user: user.toJSON()
    });

  } catch (error) {
    console.error('Email verification error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during email verification'
    });
  }
});

router.post('/resend-verification', emailVerificationLimiter, async (req, res) => {
  try {
    const { email } = req.body;
    
    if (!email) {
      return res.status(400).json({
        success: false,
        message: 'Email is required'
      });
    }

    const user = await User.findByEmail(email);
    if (!user) {
      return res.status(404).json({
        success: false,
        message: 'User not found'
      });
    }

    if (user.email_verified) {
      return res.status(400).json({
        success: false,
        message: 'Email is already verified'
      });
    }

    const verificationToken = await user.regenerateEmailVerificationToken();
    const verificationUrl = `${process.env.FRONTEND_URL}/verify-email/${verificationToken}`;
    const emailHtml = getEmailVerificationTemplate(verificationUrl, user.name);
    
    await sendEmail(
      user.email,
      'Verify Your Email Address',
      emailHtml
    );

    res.json({
      success: true,
      message: 'Verification email sent successfully'
    });

  } catch (error) {
    console.error('Resend verification error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error while sending verification email'
    });
  }
});

router.post('/login', authLimiter, validateLogin, async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await User.findByEmail(email);
    if (!user) {
      return res.status(401).json({
        success: false,
        message: 'Invalid credentials'
      });
    }

    const isPasswordValid = await user.validatePassword(password);
    if (!isPasswordValid) {
      return res.status(401).json({
        success: false,
        message: 'Invalid credentials'
      });
    }

    const { token } = generateToken(user.id);

    res.json({
      success: true,
      message: 'Login successful',
      token,
      user: user.toJSON(),
      emailVerified: user.email_verified
    });

  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during login'
    });
  }
});

router.post('/forgot-password', passwordResetLimiter, async (req, res) => {
  try {
    const { email } = req.body;

    if (!email) {
      return res.status(400).json({
        success: false,
        message: 'Email is required'
      });
    }

    const user = await User.findByEmail(email);
    if (!user) {
      return res.json({
        success: true,
        message: 'If an account with that email exists, you will receive a password reset email'
      });
    }
    const resetToken = await user.generatePasswordResetToken();
    const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${resetToken}`;
    const emailHtml = getPasswordResetTemplate(resetUrl, user.name);
    
    await sendEmail(
      user.email,
      'Password Reset Request',
      emailHtml
    );

    res.json({
      success: true,
      message: 'If an account with that email exists, you will receive a password reset email'
    });

  } catch (error) {
    console.error('Forgot password error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error while processing password reset request'
    });
  }
});

router.post('/reset-password', validatePasswordReset, async (req, res) => {
  try {
    const { token, password } = req.body;

    const user = await User.findByPasswordResetToken(token);
    if (!user) {
      return res.status(400).json({
        success: false,
        message: 'Invalid or expired reset token'
      });
    }

    await user.resetPassword(password);

    res.json({
      success: true,
      message: 'Password reset successfully'
    });

  } catch (error) {
    console.error('Reset password error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during password reset'
    });
  }
});

router.get('/profile', authenticateToken, requireEmailVerification, (req, res) => {
  res.json({
    success: true,
    message: 'Profile retrieved successfully',
    user: req.user.toJSON()
  });
});

router.put('/profile', authenticateToken, requireEmailVerification, async (req, res) => {
  try {
    const { name } = req.body;

    if (!name) {
      return res.status(400).json({
        success: false,
        message: 'Name is required'
      });
    }

    await req.user.updateProfile(name);

    res.json({
      success: true,
      message: 'Profile updated successfully',
      user: req.user.toJSON()
    });

  } catch (error) {
    console.error('Profile update error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during profile update'
    });
  }
});

router.post('/refresh', authenticateToken, (req, res) => {
  try {
    const { token: newToken } = generateToken(req.user.id);

    res.json({
      success: true,
      message: 'Token refreshed successfully',
      token: newToken,
      user: req.user.toJSON()
    });

  } catch (error) {
    console.error('Token refresh error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during token refresh'
    });
  }
});

router.post('/logout', authenticateToken, async (req, res) => {
  try {
    if (req.tokenDecoded.jti) {
      const expiresAt = new Date(req.tokenDecoded.exp * 1000);
      await TokenBlacklist.addToBlacklist(req.tokenDecoded.jti, req.user.id, expiresAt);
    }

    res.json({
      success: true,
      message: 'Logged out successfully'
    });

  } catch (error) {
    console.error('Logout error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during logout'
    });
  }
});

router.post('/logout-all', authenticateToken, async (req, res) => {
  try {
    await TokenBlacklist.revokeAllUserTokens(req.user.id);

    res.json({
      success: true,
      message: 'Logged out from all devices successfully'
    });

  } catch (error) {
    console.error('Logout all error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during logout from all devices'
    });
  }
});

router.post('/change-password', authenticateToken, requireEmailVerification, async (req, res) => {
  try {
    const { currentPassword, newPassword } = req.body;

    if (!currentPassword || !newPassword) {
      return res.status(400).json({
        success: false,
        message: 'Current password and new password are required'
      });
    }

    const isCurrentPasswordValid = await req.user.validatePassword(currentPassword);
    if (!isCurrentPasswordValid) {
      return res.status(400).json({
        success: false,
        message: 'Current password is incorrect'
      });
    }

    const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/;
    if (newPassword.length < 8 || !passwordRegex.test(newPassword)) {
      return res.status(400).json({
        success: false,
        message: 'New password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character'
      });
    }

    await req.user.resetPassword(newPassword);
    await TokenBlacklist.revokeAllUserTokens(req.user.id);

    res.json({
      success: true,
      message: 'Password changed successfully. Please log in again with your new password.'
    });

  } catch (error) {
    console.error('Change password error:', error);
    res.status(500).json({
      success: false,
      message: 'Server error during password change'
    });
  }
});

module.exports = router;

These protected routes demonstrate accessing user profiles, updating user data, and retrieving admin-level information. The database operations are handled asynchronously with proper error handling.

Token Refresh Mechanism

Implement token refresh functionality for maintaining user sessions:

router.post('/refresh', authenticateToken, (req, res) => {
  try {
    const newToken = generateToken(req.user.id);

    res.json({
      success: true,
      message: 'Token refreshed successfully',
      token: newToken,
      user: req.user.toJSON()
    });

  } catch (error) {
    res.status(500).json({
      success: false,
      message: 'Server error during token refresh'
    });
  }
});

router.post('/logout', authenticateToken, (req, res) => {
  res.json({
    success: true,
    message: 'Logged out successfully'
  });
});

Token refresh allows extending user sessions without storing refresh tokens in the database. The short-lived access tokens (15 minutes) minimize exposure if compromised, while the refresh mechanism maintains user experience by preventing frequent logouts.

The JTI (JWT ID) field enables precise token blacklisting when users log out or change passwords. This stateless approach scales better than session storage while maintaining security through token rotation.

Security Enhancements

Add security headers and error handling to the main server:

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const dotenv = require('dotenv');
const { generalLimiter } = require('./middleware/rateLimiter');

dotenv.config();

const app = express();

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
}));

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (like mobile apps or curl requests)
    if (!origin) return callback(null, true);
    
    const allowedOrigins = process.env.ALLOWED_ORIGINS ? 
      process.env.ALLOWED_ORIGINS.split(',') : 
      ['http://localhost:3000'];
    
    if (allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true, 
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
  exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
};

app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: false, limit: '10mb' }));
app.use(generalLimiter);
app.use('/api/auth', require('./routes/auth'));

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: Math.floor(process.uptime()),
    version: process.env.npm_package_version || '1.0.0'
  });
});

app.get('/', (req, res) => {
  res.json({ 
    message: 'JWT Auth API is running',
    version: '2.0.0',
    features: [
      'JWT Authentication',
      'Email Verification', 
      'Password Reset',
      'Rate Limiting',
      'Token Revocation',
      'CORS Protection'
    ]
  });
});

app.use((err, req, res, next) => {
  console.error('Global error:', err);

  if (err.message === 'Not allowed by CORS') {
    return res.status(403).json({
      success: false,
      message: 'CORS policy violation'
    });
  }
  
  if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
    return res.status(400).json({
      success: false,
      message: 'Invalid JSON payload'
    });
  }
  
  res.status(500).json({
    success: false,
    message: process.env.NODE_ENV === 'production' 
      ? 'Internal server error' 
      : err.message
  });
});

app.use((req, res) => {
  res.status(404).json({
    success: false,
    message: 'Route not found'
  });
});

const PORT = process.env.PORT || 3000;

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Security features active`);
  console.log(`CORS configured for: ${process.env.ALLOWED_ORIGINS || 'http://localhost:3000'}`);
});

process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully...');
  server.close(() => {
    console.log('Process terminated');
  });
});

process.on('SIGINT', () => {
  console.log('SIGINT received. Shutting down gracefully...');
  server.close(() => {
    console.log('Process terminated');
  });
});

The security stack implements defense in depth. Helmet adds security headers that prevent XSS, clickjacking, and content sniffing attacks.

The CORS configuration restricts which domains can access the API, preventing unauthorized cross-origin requests.

Rate limiting is applied globally to prevent API abuse. The graceful shutdown handling ensures database connections are properly closed when the server stops, preventing data corruption and connection leaks.

Testing the Authentication System

Test the implementation using curl commands:

# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "P@ssword123"
  }'

# Login with credentials
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "password": "P@ssword123"
  }'

# Access protected route
curl -X GET http://localhost:3000/api/auth/profile \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

# Test Rate Limiting. This should trigger rate limiting after 5 attempts
for i in {1..10}; do
  curl -X POST http://localhost:3000/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"wrong@email.com","password":"wrongpass"}'
  echo "Attempt $i"
done

These commands test the complete authentication flow from registration through accessing protected resources.

Conclusion

In this article, we demonstrated how to implement secure JWT authentication Node.js step by step. This authentication system provides a solid foundation for building secure user login flows in Node.js applications.

The implementation covers essential features such as password hashing, token generation and verification, protected routes, and key security best practices.

You can use this as a boilerplate to quickly kick-start your own authentication system in any Node.js project.

The full source code for this Node.js JWT authentication tutorial is available on GitHub

Leave a Comment