Implementing JWT Authentication with Access Tokens and Refresh Tokens in Node.js

Web application security is crucial in today's digital landscape, especially when sensitive data and user information are involved. One of the most important aspects of web application security is authentication, which verifies the identity of users and devices seeking access to resources.

JSON Web Tokens (JWT) is a popular and straightforward method for implementing authentication in web applications. JWT is a self-contained, digitally signed token in JSON format that can encode various data, such as user ID, expiration time, and other custom claims. It is a stateless mechanism that enables cross-domain authentication and prevents server-side storage of sessions and cookies.

In this blog, we will discuss how to implement JWT authentication with access tokens and refresh tokens in Node.js, a popular backend platform. We will cover the following topics:

  • Installing Dependencies

  • Understanding Access Tokens and Refresh Tokens

  • Registering and Logging in Users

  • Generating and Refreshing Tokens

  • Protecting Endpoints with JWT Authentication

  • Storing and Revoking Refresh Tokens

  • Conclusion

So let's begin.

Installing Dependencies

First, we need to install some dependencies that we'll use in this project. We'll use Express as the web framework, Mongoose as the object modeling library for MongoDB, bcrypt for password hashing, and jsonwebtoken for generating and verifying JWT.

npm install express mongoose bcrypt jsonwebtoken

Understanding Access Tokens and Refresh Tokens

Before we dive into code, let's briefly discuss access tokens and refresh tokens and their roles in JWT authentication.

Access tokens are short-lived tokens that grant the holder permission to access a protected resource, such as an API endpoint, a user's data, or other system resources. The token contains information about the user, including their identity and privileges, along with a limited lifespan (expiry), to enforce revocation and reduce security risks.

Refresh tokens are long-lived tokens that enable a user to obtain a new access token without re-authenticating with their credentials, such as username and password. The refresh token is used to renew the access token if it has expired or invalidated due to revocation, without exposing the user's credentials again.

Registering and Logging in Users

The first step to implement JWT authentication is to register new users and provide them with access credentials. We'll start by defining a user model using Mongoose that will store a user's credentials.

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  username: { type: String, required: true },
  password: { type: String, required: true }
});

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

Next, we'll define an API endpoint for creating a new user. This endpoint will receive the user's credentials, hash the password using bcrypt, and save the user to the database.

const express = require('express');
const bcrypt = require('bcrypt');
const User = require('./models/User'); // Import your user model

const app = express();

// Set up middleware for parsing JSON in request body
app.use(express.json());

// Endpoint for registering a new user
app.post('/register', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Hash the password
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // Create a new user with the hashed password
    const user = new User({
      username,
      password: hashedPassword
    });

    // Save the user to the database
    await user.save();

    res.send('User created successfully');
  } catch (err) {
    console.error(err);
    res.status(500).send('Server error');
  }
});

Note that we use the async/await pattern to handle asynchronous operations such as hashing the password and saving the user to the database.

Next, we'll define an API endpoint for logging in a user and issuing a JWT access token and a refresh token. This endpoint will receive the user's credentials, validate them, generate and return tokens, and store the refresh token in the database or a store.

const jwt = require('jsonwebtoken');

// Set up a secret key for signing tokens
const JWT_SECRET = 'my-secret-key';

// Endpoint for logging in and creating access and refresh tokens
app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Check if username exists
    const user = await User.findOne({ username });
    if (!user) throw new Error('Invalid username or password');

    // Check if password is correct
    const isPasswordCorrect = await bcrypt.compare(password, user.password);
    if (!isPasswordCorrect) throw new Error('Invalid username or password');

    // Generate an access token and a refresh token
    const accessToken = jwt.sign({ userId: user._id }, JWT_SECRET, { expiresIn: '15m' });
    const refreshToken = jwt.sign({ userId: user._id }, JWT_SECRET);

    // Save the refresh token in the database or an in-memory store
    // You can also store the refresh token in an encrypted cookie or in the client's local storage
    // Be aware of the security implications of storing refresh tokens on the client-side
    // If you decide to do so, make sure to implement appropriate security measures
    addRefreshToken(refreshToken);

    res.json({ accessToken, refreshToken });
  } catch (err) {
    console.error(err);
    res.status(401).send('Invalid username or password');
  }
});

Here, we use the jsonwebtoken package to generate an access token and a refresh token. We encode the user ID as a payload of the token to enable the server to verify the token's authenticity later when it is presented. We set the access token's lifespan to 15 minutes to ensure timely expiration of the token.

Note that we use the throw keyword to indicate an error instead of sending a response when an error occurs. We catch this error in the catch block, log it for debugging purposes, and send an appropriate response to the user.

Generating and Refreshing Tokens

Now, let's define an API endpoint for refreshing an access token using a refresh token. This endpoint will receive the refresh token, validate it, decode it to obtain the user ID, and generate and return a new access token.

// Endpoint for refreshing an access token using a refresh token
app.post('/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    // Check if refresh token is valid
    if (!isRefreshTokenValid(refreshToken)) throw new Error('Invalid refresh token');

    // Decode the refresh token to get the user ID
    const decoded = jwt.verify(refreshToken, JWT_SECRET);

    // Generate a new access token
    const accessToken = jwt.sign({ userId: decoded.userId }, JWT_SECRET, { expiresIn: '15m' });

    res.json({ accessToken });
  } catch (err) {
    console.error(err);
    res.status(401).send('Invalid refresh token');
  }
});

Here, we define a helper function isRefreshTokenValid() that checks if the refresh token exists in the database or store and has not expired yet. If the refresh token is valid, we verify and decode the refresh token to obtain the user ID embedded in the token. Then, we generate a new access token with the user ID and return it to the user.

Note that we use the same jsonwebtoken package and the JWT_SECRET key to generate and verify the access token and refresh token. We also set the access token's lifespan to be the same as the original access token to maintain consistency.

Protecting Endpoints with JWT Authentication

Now, let's use the access token to secure endpoints that require authentication. We'll define a protected endpoint that only authorized users can access, using the authenticateToken() middleware function.

// Sample protected endpoint that requires an access token to access
app.get('/protected', authenticateToken, (req, res) => {
  res.send('This is a protected endpoint');
});

// Middleware function to authenticate an access token
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const accessToken = authHeader && authHeader.split(' ')[1];

  if (!accessToken) return res.status(401).send('Access token not found');

  jwt.verify(accessToken, JWT_SECRET, (err, decoded) => {
    if (err) return res.status(403).send('Access token invalid');

    req.userId = decoded.userId;
    next();
  });
}

Here, we define a middleware function authenticateToken() that extracts the access token from the request header, verifies it using the jsonwebtoken package, and sets the user ID in the request object for further processing by the endpoint handler. If the access token is invalid or not found, we send an appropriate response to the user.

We use the middleware function authenticateToken() to secure the /protected endpoint, which requires a valid access token to access.

Storing and Revoking Refresh Tokens

Finally, let's discuss how to store and revoke refresh tokens securely. We recommend storing refresh tokens in a database or an in-memory store like Redis, and associate each refresh token with a user ID to enable quick retrieval and revocation.

// Helper function to add a refresh token to the database or an in-memory store
function addRefreshToken(refreshToken) {
  // Add the refresh token to the store
}

// Helper function to check if a refresh token is valid
function isRefreshTokenValid(refreshToken) {
  // Check if the refresh token exists in the store and is not expired
  return true;
}

Here, we define two helper functions: addRefreshToken() to store a refresh token and isRefreshTokenValid() to check if a refresh token is valid. We recommend implementing appropriate cache eviction and refresh token revocation mechanisms to ensure the refresh tokens' security and integrity.

Conclusion

In this blog, we have discussed how to implement JWT authentication with access tokens and refresh tokens in Node.js. We have covered how to install dependencies, register and log in users, generate and refresh tokens, protect endpoints with JWT authentication, and store and revoke refresh tokens. Implementing JWT authentication in your web application can significantly improve its security and enhance user experience. We hope you find this blog useful and informative.