How I handle jwt authentication on the backend including refresh tokens and cookies with express.js

Bishop AbrahamBishop Abraham
5 min read

Authentication is the backbone of any secure web app. After building several projects using Express.js, I’ve settled on a simple but flexible approach that balances security, scalability, and ease of use.

In this post, I’ll walk through how I handle authentication on the backend using Express, including how I:

  • Register and log in users

  • Secure passwords

  • Generate and verify tokens

  • Protect routes


🧱 Tech Stack

  • Node.js & Express – Backend server

  • MongoDB & Mongoose – User data storage

  • bcryptjs – Password hashing

  • jsonwebtoken – Token generation and verification

  • dotenv – Environment variables

  • cookie-parser - a package to receive and parse cookie properly


🔐 Step 1: User Registration

const bcrypt = require('bcryptjs');
const User = require('../models/User');

app.post('/api/register', async (req, res) => {
  const { username, email, password } = req.body;

  const hashedPassword = await bcrypt.hash(password, 10);

  const user = new User({ username, email, password: hashedPassword });

  try {
    await user.save();
    res.status(201).json({ message: 'User created successfully' });
  } catch (err) {
    res.status(400).json({ error: 'User already exists or invalid input' });
  }
});

Quick explanation:

I retrieved the username, email and password from the request body. Make sure to do app.use(express.json()) so as to receive json properly. The password is then hashed to prevent hackers from getting access to the users account after a data breach or something. A new user is then created with the user model. The hashed password is used instead of the raw password.


🔑 Step 2: User Login & JWT Generation (Refresh & Access Token)

const jwt = require('jsonwebtoken');

const generateAccessToken = (payload) => {
    return jwt.sign(payload, process.env.JWT_SECRET, {expiresIn: '15m'})
}

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

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

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });

    const payload = {
        id: user._id,
        username: user.username,
        email: user.email
    }

      const refreshToken = jwt.sign(
        payload,
        process.env.JWT_REFRESH_SECRET,
        { expiresIn: '5d' }
      );

    const accessToken = generateAccessToken(payload);

    // Send the refresh token through a http-only cookie 
    res.cookie("refreshToken", refreshToken, {
        secure: true,
        httpOnly: true,
        maxAge: 1000 * 60 * 60 * 24 * 7 // expires in 7 days
    })

      res.status(200).json({ accessToken});
});

Quick explanation:

I receive the email and password from the request body. The email is a unique parameter so I make use of the email to find the user. If the user exists, the password received from the request body is compared to the one in the database using the bcrypt package. If it matches, the refresh token and access token is created by signing the payload formed from the user object with the respective secrets of both tokens.

It is more reasonable for the refresh token to have a longer expiry date than the access token because the refresh token is used to generate the access tokens. After creating both tokens, the refresh token is sent through a http-only cookie, this is more secure than passing it through the response. The access token is sent through the response.


Step 3: Endpoint to refresh access token

In order to receive cookies properly, do this in the main.js or server.js file


const cookieParser = require("cookie-parser");

app.use(cookieParser());

Code to refresh the access token


app.post('/token/refresh', (req, res) => {

    const refreshToken = req.cookies.refreshToken;
    if (!refreshToken) return res.statusCode(401)

    const verifiedToken = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)
    if (!verifiedToken) return res.statusCode(403)

    const {id, name, email} = verifiedToken

    const accessToken = generateAccessToken({id, name, email});

    res.status(200).json({
        accessToken
    })

});

Quick explanation:

The refresh token is extracted from the http-only cookie sent through the request object. If the token is not undefined, jwt goes ahead to verify it with the refresh secret in the .env file. The jwt verify function returns the payload that was used to sign the refresh token with some additional parameters like the iat (issued at date) and the exp (expiry date). We only need the id, name, and email from the verified token to create a new payload which will be used to create the access token.


🛡️ Step 4: Protecting Routes with Middleware

const authMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) return res.status(401).json({ error: 'No token provided' });

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    res.status(403).json({ error: 'Invalid or expired token' });
  }
};

// Example protected route
app.get('/api/profile', authMiddleware, async (req, res) => {
  const user = await User.findById(req.user.userId).select('-password');
  res.json(user);
});

Quick explanation:

The auth middleware is necessary for authentication of users. We have to know the user trying to access some endpoints in the website and we have to know if the token is still valid or not. This is how the code works; the token is usually passed through the authorization header in the format “Bearer token”. So first, we have to get the authorization header content. In order to get the token, we can split the “Bearer token” by the space. It returns something like [“Bearer”,”token”]. We can the get the token by accessing the second item in the array. Simple logic :) . So we verify the token with the secret in the .env file and then attach the decoded token to the request object. Finally, call the next function so as to go to the next function.


🌍 Why I Like This Setup

  • Stateless – Everything is handled with tokens, so no session storage unless I explicitly want them.

  • Scalable – Works well across multiple clients (web, mobile).

  • Secure – Passwords are hashed, and JWTs are signed and time-limited.


🧩 What Could Be Improved

Implement role-based access control (RBAC)


Final Thoughts

This setup has worked well for my projects, and it's easy to build on top of. If you're just starting out with backend auth or looking for a straightforward JWT-based approach, I hope this helps!

Let me know how you handle authentication differently. I’m always open to learning new tricks.

10
Subscribe to my newsletter

Read articles from Bishop Abraham directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bishop Abraham
Bishop Abraham