How to Implement JWT Authentication in Node.js: A Complete Guide

Why JWT Authentication in Node.js Still Dominates in 2026

If you are building APIs or web applications with Node.js, chances are you need a reliable way to authenticate users. JWT authentication in Node.js remains one of the most widely adopted approaches because it is stateless, scalable, and straightforward to implement.

JSON Web Tokens (JWT) let your server verify a user’s identity without storing session data in memory or a database. The token itself carries all the information needed, signed cryptographically so it cannot be tampered with.

In this guide, we will walk through every step required to build a production-ready JWT authentication system in Node.js. You will get annotated code examples you can drop into a real project, along with explanations of why each decision matters.

What You Will Learn

  • How JWTs work under the hood
  • Setting up a Node.js project with Express
  • User registration and password hashing
  • Generating access tokens and refresh tokens
  • Protecting routes with authentication middleware
  • Implementing a token refresh flow
  • Common security pitfalls and how to avoid them

Prerequisites

Before we start coding, make sure you have the following ready:

  • Node.js 20+ installed (LTS recommended)
  • A package manager: npm or pnpm
  • Basic knowledge of Express.js
  • A MongoDB instance (local or MongoDB Atlas) or any database of your choice
  • A code editor such as VS Code

How JWT Authentication Works: A Quick Overview

Before touching any code, let’s understand the flow at a high level.

  1. The user sends their credentials (email and password) to the server.
  2. The server verifies the credentials against the database.
  3. If valid, the server creates a JWT access token (short-lived) and a refresh token (long-lived) and sends both to the client.
  4. The client stores the tokens and includes the access token in the Authorization header of every subsequent request.
  5. A middleware on the server verifies the token before granting access to protected resources.
  6. When the access token expires, the client uses the refresh token to obtain a new one without forcing the user to log in again.

Anatomy of a JWT

A JSON Web Token is composed of three Base64-encoded parts separated by dots:

Part Content Example Fields
Header Algorithm and token type alg: HS256, typ: JWT
Payload Claims (user data, expiration, etc.) sub, email, iat, exp
Signature HMAC or RSA signature of header + payload Used to verify integrity

Step 1: Initialize the Project

Create a new directory and initialize a Node.js project:

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

Install the packages we need:

npm install express jsonwebtoken bcryptjs dotenv mongoose cookie-parser
Package Purpose
express Web framework
jsonwebtoken Create and verify JWTs
bcryptjs Hash passwords
dotenv Load environment variables
mongoose MongoDB ODM
cookie-parser Parse cookies (for refresh tokens)

Step 2: Project Structure

Keep things organized from the start. Here is a simple structure that scales well:

jwt-auth-demo/
├── controllers/
│   └── authController.js
├── middleware/
│   └── authMiddleware.js
├── models/
│   └── User.js
├── routes/
│   └── authRoutes.js
├── utils/
│   └── tokenUtils.js
├── .env
├── server.js
└── package.json

Step 3: Configure Environment Variables

Create a .env file in the project root. Never commit this file to version control.

PORT=4000
MONGO_URI=mongodb://localhost:27017/jwt-auth-demo
ACCESS_TOKEN_SECRET=your_access_token_secret_here_replace_me
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here_replace_me
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

Tip: Generate strong secrets with node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Step 4: Create the User Model

File: models/User.js

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true, minlength: 8 },
  refreshTokens: [String] // store hashed refresh tokens
}, { timestamps: true });

// Hash password before saving
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Compare candidate password with stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);

Notice we store an array of hashed refresh tokens on the user document. This allows us to invalidate specific tokens later, which is critical for logout and token rotation.

Step 5: Token Utility Functions

File: utils/tokenUtils.js

const jwt = require('jsonwebtoken');

/**
 * Generate an access token (short-lived)
 */
function generateAccessToken(user) {
  return jwt.sign(
    { userId: user._id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY } // e.g. '15m'
  );
}

/**
 * Generate a refresh token (long-lived)
 */
function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user._id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY } // e.g. '7d'
  );
}

/**
 * Verify an access token
 */
function verifyAccessToken(token) {
  return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
}

/**
 * Verify a refresh token
 */
function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken
};

Step 6: Authentication Controller

This is where the core logic lives: registration, login, token refresh, and logout.

File: controllers/authController.js

6a. Register

const User = require('../models/User');
const bcrypt = require('bcryptjs');
const {
  generateAccessToken,
  generateRefreshToken,
  verifyRefreshToken
} = require('../utils/tokenUtils');

exports.register = async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(409).json({ message: 'Email already registered' });
    }

    // Create user (password hashing happens in the pre-save hook)
    const user = await User.create({ name, email, password });

    // Generate tokens
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    // Store hashed refresh token
    const hashedRT = await bcrypt.hash(refreshToken, 10);
    user.refreshTokens.push(hashedRT);
    await user.save({ validateBeforeSave: false });

    // Send refresh token as httpOnly cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,       // set to true in production (HTTPS)
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    return res.status(201).json({ accessToken, user: { id: user._id, name, email } });
  } catch (err) {
    return res.status(500).json({ message: 'Server error', error: err.message });
  }
};

6b. Login

exports.login = async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ message: 'Invalid email or password' });
    }

    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(401).json({ message: 'Invalid email or password' });
    }

    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    // Store hashed refresh token
    const hashedRT = await bcrypt.hash(refreshToken, 10);
    user.refreshTokens.push(hashedRT);
    await user.save({ validateBeforeSave: false });

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    return res.json({ accessToken, user: { id: user._id, name: user.name, email } });
  } catch (err) {
    return res.status(500).json({ message: 'Server error', error: err.message });
  }
};

6c. Refresh Token

exports.refresh = async (req, res) => {
  try {
    const token = req.cookies.refreshToken;
    if (!token) {
      return res.status(401).json({ message: 'Refresh token missing' });
    }

    // Verify the refresh token signature
    let payload;
    try {
      payload = verifyRefreshToken(token);
    } catch (err) {
      return res.status(403).json({ message: 'Invalid or expired refresh token' });
    }

    const user = await User.findById(payload.userId);
    if (!user) {
      return res.status(403).json({ message: 'User not found' });
    }

    // Check that this refresh token exists in the user's stored tokens
    const tokenExists = await Promise.any(
      user.refreshTokens.map(ht => bcrypt.compare(token, ht).then(match => {
        if (match) return ht;
        throw new Error('no match');
      }))
    ).catch(() => null);

    if (!tokenExists) {
      // Possible token reuse attack: clear all refresh tokens
      user.refreshTokens = [];
      await user.save({ validateBeforeSave: false });
      return res.status(403).json({ message: 'Token reuse detected. All sessions revoked.' });
    }

    // Remove the old refresh token
    user.refreshTokens = user.refreshTokens.filter(ht => ht !== tokenExists);

    // Issue new tokens (rotation)
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user);

    const newHashedRT = await bcrypt.hash(newRefreshToken, 10);
    user.refreshTokens.push(newHashedRT);
    await user.save({ validateBeforeSave: false });

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    return res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(500).json({ message: 'Server error', error: err.message });
  }
};

Notice the refresh token rotation pattern: every time a refresh token is used, the old one is invalidated and a new one is issued. If someone tries to reuse an old refresh token, all tokens for that user are wiped, forcing a fresh login.

6d. Logout

exports.logout = async (req, res) => {
  try {
    const token = req.cookies.refreshToken;
    if (token) {
      const payload = verifyRefreshToken(token);
      const user = await User.findById(payload.userId);
      if (user) {
        // Remove the matching refresh token
        const remaining = [];
        for (const ht of user.refreshTokens) {
          const match = await bcrypt.compare(token, ht);
          if (!match) remaining.push(ht);
        }
        user.refreshTokens = remaining;
        await user.save({ validateBeforeSave: false });
      }
    }

    res.clearCookie('refreshToken', {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict'
    });

    return res.json({ message: 'Logged out successfully' });
  } catch (err) {
    // Even if token verification fails, clear the cookie
    res.clearCookie('refreshToken');
    return res.json({ message: 'Logged out' });
  }
};

Step 7: Authentication Middleware

This middleware sits in front of any route you want to protect. It extracts the access token from the Authorization header and verifies it.

File: middleware/authMiddleware.js

const { verifyAccessToken } = require('../utils/tokenUtils');

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'Access token required' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyAccessToken(token);
    req.user = decoded; // attach user info to request
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ message: 'Access token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(403).json({ message: 'Invalid access token' });
  }
}

module.exports = authenticate;

Returning a specific code like TOKEN_EXPIRED helps the client know when to silently refresh the token instead of redirecting to a login page.

Step 8: Routes

File: routes/authRoutes.js

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const authenticate = require('../middleware/authMiddleware');

// Public routes
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);

// Protected route example
router.get('/profile', authenticate, (req, res) => {
  res.json({ message: 'This is a protected route', user: req.user });
});

module.exports = router;

Step 9: Server Entry Point

File: server.js

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/authRoutes');

const app = express();

// Middleware
app.use(express.json());
app.use(cookieParser());

// Routes
app.use('/api/auth', authRoutes);

// Connect to MongoDB and start server
mongoose.connect(process.env.MONGO_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(process.env.PORT, () => {
      console.log(`Server running on port ${process.env.PORT}`);
    });
  })
  .catch(err => console.error('MongoDB connection error:', err));

Step 10: Test the API

Use any HTTP client (Postman, Insomnia, or cURL) to test:

Register

POST http://localhost:4000/api/auth/register
Content-Type: application/json

{
  "name": "Alice",
  "email": "[email protected]",
  "password": "securePassword123"
}

Login

POST http://localhost:4000/api/auth/login
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "securePassword123"
}

Access Protected Route

GET http://localhost:4000/api/auth/profile
Authorization: Bearer <your_access_token>

Refresh Token

POST http://localhost:4000/api/auth/refresh
(cookie with refreshToken is sent automatically)

Common Security Pitfalls (and How to Avoid Them)

Building JWT authentication is not just about making it work. It is about making it safe. Here are the most common mistakes developers make:

Pitfall Risk Solution
Storing JWTs in localStorage Vulnerable to XSS attacks Store refresh tokens in httpOnly cookies. Keep access tokens in memory only.
Using a weak secret Tokens can be forged Generate secrets with at least 256 bits of entropy using crypto.randomBytes(64).
Long-lived access tokens Stolen tokens stay valid too long Keep access token expiry short (5 to 15 minutes). Use refresh tokens for longevity.
No refresh token rotation Token theft goes undetected Rotate refresh tokens on every use. Invalidate all tokens if reuse is detected.
Not validating the algorithm “none” algorithm attack The jsonwebtoken library rejects “none” by default, but always specify your expected algorithm explicitly.
Putting sensitive data in the payload JWTs are only encoded, not encrypted Only include non-sensitive identifiers (user ID, role). Never put passwords or PII in the token.
Missing HTTPS in production Tokens can be intercepted Always use HTTPS. Set the secure flag on cookies.

Bonus: Role-Based Access Control

Once authentication is in place, you often need authorization too. Here is a simple role-checking middleware you can chain after the authenticate middleware:

function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user || !allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ message: 'Forbidden: insufficient permissions' });
    }
    next();
  };
}

// Usage:
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);

To support this, add a role field to your User model and include it in the access token payload.

Access Token vs. Refresh Token: Quick Comparison

Feature Access Token Refresh Token
Lifetime Short (5-15 minutes) Long (days to weeks)
Where stored In-memory (JavaScript variable) httpOnly cookie
Sent with every request? Yes, in Authorization header Only to the /refresh endpoint
Can be revoked? Only by waiting for expiry (unless using a blocklist) Yes, by removing from the database

When to Consider Alternatives to JWT

JWT is not the right choice for every scenario. Consider alternatives if:

  • You need instant token revocation across all endpoints (session-based auth may be simpler)
  • Your tokens grow large due to many claims (look into opaque tokens with a token introspection endpoint)
  • You are building a monolithic server-rendered app where sessions work perfectly fine

For APIs, microservices, and SPAs, JWT remains an excellent choice.

Summary

Here is a quick recap of everything we built:

  1. Set up a Node.js + Express project with MongoDB
  2. Created a User model with password hashing via bcrypt
  3. Built utility functions to generate and verify JWTs
  4. Implemented register, login, refresh, and logout endpoints
  5. Added an authentication middleware to protect routes
  6. Implemented refresh token rotation with reuse detection
  7. Covered common security pitfalls and how to prevent them

You now have a solid, production-grade JWT authentication system in Node.js. From here, you can extend it with email verification, password reset flows, rate limiting, and more.

Frequently Asked Questions

What is JWT authentication in Node.js?

JWT (JSON Web Token) authentication in Node.js is a method where the server issues a digitally signed token after a user logs in. The client sends this token with subsequent requests, and the server verifies its signature to confirm the user’s identity without maintaining server-side sessions.

Where should I store JWTs on the client side?

Store access tokens in memory (a JavaScript variable) and refresh tokens in httpOnly, secure cookies. Avoid localStorage or sessionStorage for tokens because they are accessible via JavaScript and vulnerable to XSS attacks.

How long should a JWT access token last?

A common best practice is 5 to 15 minutes. The short lifespan limits the damage window if a token is compromised. Use refresh tokens to issue new access tokens without requiring the user to log in again.

What happens when a JWT expires?

When an access token expires, the server returns a 401 response. The client should then call the refresh endpoint with the refresh token to get a new access token. If the refresh token is also expired or invalid, the user must log in again.

Is JWT better than session-based authentication?

It depends on your architecture. JWT is ideal for stateless APIs, microservices, and single-page applications. Session-based authentication can be simpler for traditional server-rendered applications. JWT scales more easily because no session store is needed on the server.

Can a JWT be revoked?

JWTs are stateless by design, so they cannot be individually revoked before expiry unless you implement a token blocklist (which adds server-side state). For refresh tokens, you can revoke them by removing them from the database, which prevents new access tokens from being issued.

What is refresh token rotation and why does it matter?

Refresh token rotation means issuing a new refresh token every time the old one is used, and invalidating the old one. If an attacker steals a refresh token and the legitimate user also uses it, the server detects the reuse and revokes all tokens for that user, stopping the attack.

Leave a Comment

Your email address will not be published. Required fields are marked *