Refresh Auth Token Rotation (Node js & React ) — Part 1

Tókos Bence
14 min readMar 18, 2024

--

Auth Token Rotation ( Node js & React js )

These days, security is increasingly crucial. So, we need to be clear about at least the basics of security concerns and tips & tricks. Many websites out there have weak authentication, and even if your application doesn’t contain any sensitive information, such as just a mail address, you need to respect your users and provide minimal security. In this article, I will demonstrate a simple JWT token-based authentication system with access and refresh tokens. We will use a Node.js backend, React for the frontend, and MongoDB. If you aren’t familiar with these technologies, please take some time to familiarize yourself with them before diving in. However, if you have some JavaScript skills and basic REST knowledge, then come along and delve into this with me. :)

Let’s start by setting up a Node.js backend. First, initialize the Node application:

npm init

Next, install Express:

npm install express

Here’s the file and folder hierarchy we’ll use in the backend:

Folder structure

Create the server.js file:

const express = require("express");
const port = 5000;
const app = express();

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

Now, let’s add a database for our app. Install MongoDB. If you’re not familiar, here is a useful link.

npm install mongoose

Create the database connection file:

const mongoose = require("mongoose");

const db = mongoose
.connect("mongodb://127.0.0.1/TokenRotation") //TokenRotation is the db name
.then(() => console.log("Connected to MongoDB..."))
.catch((err) => console.error("Could not connect to MongoDB...", err));

module.exports = db;

Don’t forget to add the database connection to server.js:

const db = require("./dbconnection");

Now, let’s install some additional packages:

npm install jsonwebtoken
npm install dotenv
npm install bcrypt

Now, let’s create the User model:

const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
require("dotenv").config();

const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
unique: true,
minlength: 8,
maxlength: 1024,
},
refreshtoken: [String],
});

userSchema.methods.generateAuthToken = function () {
const accesstoken = jwt.sign(
{ _id: this._id },
process.env.ACCESS_SECRET_KEY,
{
expiresIn: "10s",
}
);
const refreshtoken = jwt.sign(
{ _id: this._id },
process.env.REFRESH_SECRET_KEY,
{ expiresIn: "1d" }
);

const tokens = { accesstoken: accesstoken, refreshtoken: refreshtoken };
return tokens;
};

const User = mongoose.model("User", userSchema);

exports.User = User;

We define a schema for our user data in MongoDB.

Each user document will have an email, password, and an array to store refresh tokens.

We add a method to our user schema called generateAuthToken.

  • This method creates both access and refresh tokens using the jsonwebtoken package.
  • Access token expires in 10 seconds and contains the user’s _id.
  • Refresh token expires in 1 day and also contains the user’s _id.

We create a Mongoose model named User based on the user schema.

  • This model will be used to interact with the MongoDB collection for users.
  • We export the User model for use in other parts of our application.

AppError & TryCatch

We’re enhancing our codebase with two utility functions for better error handling: AppError and tryCatch.

The AppError class is designed to encapsulate error information in a structured manner. It extends the native Error class and adds custom properties like errorCode and statusCode, allowing us to categorize errors and respond with appropriate HTTP status codes. This ensures consistency in error handling across our application.

class AppError extends Error {
constructor(errorCode, message, statusCode) {
super(message);
this.errorCode = errorCode;
this.statusCode = statusCode;
}
}

module.exports = AppError;

The tryCatch function provides a convenient way to handle errors within our asynchronous controller functions. It takes a controller function as input and returns a new asynchronous function. This new function wraps the original controller function with a try-catch block. If an error occurs during the execution of the controller function, it’s caught, logged, and passed to the Express next function for centralized error handling. This abstraction simplifies error handling in our route handlers, promoting cleaner and more maintainable code.

exports.tryCatch = (controller) => async (req, res, next) => {
try {
await controller(req, res);
} catch (error) {
console.log(error);
return next(error);
}
};

By incorporating these utility functions into our codebase, we improve the readability of our error handling mechanism, ultimately enhancing the overall reliability and user experience of our application.

Routes

Next, we’ll design the endpoints for user authentication and session management, including SignIn, SignUp, RefreshToken, and LogOut. To begin, let’s lay out the routes for these functionalities.

const express = require("express");
const authController = require("./controllers");
const verifyJWT = require("../../middleware/verifyJWT");

const router = express.Router();

//Auth based routes
router.post("/signUp", authController.SignUp);
router.post("/signIn", authController.SignIn);
router.get("/refresh", authController.refreshToken);
router.get("/logout", authController.logOut);

module.exports = router;

In a Node.js application, services and controllers are two essential components that help organize and structure the codebase, promoting separation of concerns and maintainability.

  1. Services: Services encapsulate business logic and data manipulation operations. They are responsible for performing specific tasks or operations related to a particular domain within the application. Services abstract away the details of how tasks are implemented, allowing controllers to remain lean and focused on handling HTTP requests and responses. Services are often reusable across multiple parts of the application and facilitate testing by isolating business logic from external dependencies.
  2. Controllers: Controllers handle incoming HTTP requests and define the application’s endpoints. They extract data from incoming requests, interact with services to perform necessary operations, and send back appropriate responses to clients. Controllers serve as the bridge between the client-side interface (such as a web browser or mobile app) and the underlying application logic. They organize and delegate tasks to services, ensuring that business logic remains decoupled from the presentation layer. Controllers are typically responsible for routing and request/response handling, making them a crucial component of the application’s request-response cycle.

Let’s start by implementing the SignUp functionality:

const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const { tryCatch } = require("../../utils/tryCatch");
const AppError = require("../../utils/AppError");
const authServices = require("./services");
const { User } = require("../../Models/user");

exports.SignUp = tryCatch(async (req, res) => {
const { email, password } = req.body;
const user = await User.find({ email: email });

if (!user) {
throw new AppError(
409,
"Email address already exits in the database!",
409
);
}

try {
const response = await authServices.signUp(email, password);
const accessToken = response.token.accesstoken;
const refreshToken = response.token.refreshtoken;
res.cookie("jwt", refreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.status(200).json({ accessToken: accessToken }).end();
} catch (err) {
console.log(err);
res.status(err.statusCode).json(err.message).end();
}
});

This code snippet defines a controller function SignUp, responsible for handling user registration requests in a Node.js application. It imports necessary modules such as bcrypt for password hashing and jsonwebtoken for token generation, along with custom error handling utilities. Upon receiving a request, it extracts the email and password from the request body and checks for existing users with the same email. If no duplicate email is found, it calls a SignUp service to handle user creation, generates access and refresh tokens, sets the refresh token in an HTTP-only cookie for security, and sends the access token in the response. Any errors that occur during the SignUp process are caught and handled appropriately with error status codes and messages.

The SignUp service:

const bcrypt = require("bcrypt");
const { User } = require("../../Models/user");
const { UserProfile } = require("../../Models/userProfile");
const mongoose = require("mongoose");
const ObjectId = mongoose.Types.ObjectId;

const signUp = async (email, password) => {
const newUser = new User({
email: email,
password: password,
refreshtoken: "",
});

const salt = await bcrypt.genSalt(10);
newUser.password = await bcrypt.hash(newUser.password, salt);

const tokens = newUser.generateAuthToken();
newUser.refreshtoken = tokens.refreshtoken;
await newUser.save();
const data = {
token: tokens,
id: newUser.id,
};

return data;
};

This service function signUp is responsible for creating a new user in the application. It takes an email and password as parameters. Inside the function, a new User object is created with the provided email and password, and an empty refresh token field. The password is then hashed using bcrypt for security. After generating authentication tokens using the generateAuthToken method of the User model, which is responsible for creating access and refresh tokens, the refresh token is assigned to the user object. Finally, the new user is saved to the database, and an object containing the generated tokens and the user's ID is returned. This function ensures that user passwords are securely hashed before being stored in the database and provides authentication tokens for the newly created user.

With the same schema, we’ll now implement the SignIn functionality:

exports.SignIn = tryCatch(async (req, res) => {
const cookies = req.cookies;
const { email, password } = req.body;
const user = await User.findOne({ email: email });

if (!user) {
throw new AppError(
404,
"Email address not found. Please check your email and try again.",
404
);
}

const validPassword = await bcrypt.compare(password, user.password);

if (!validPassword) {
throw new AppError(
401,
"Incorrect password. Please double-check your password and try again.",
401
);
}

try {
let newRefreshTokenArray = "";

// Check if user has an existing refresh token
if (!cookies?.jwt) {
refreshToken = user.refreshtoken;
} else {
refreshToken = cookies.jwt;
const foundToken = await User.findOne({ refreshToken }).exec();

if (!foundToken) {
console.log("Attempted refresh token reuse at login!");
// If the token is not found in the database, clear out the cookie
res.clearCookie("jwt", { httpOnly: true });
refreshToken = "";
}
}
const response = await authServices.signIn(user, newRefreshTokenArray);
const accessToken = response.token.accesstoken;
refreshToken = response.token.refreshtoken;
const profilePic = response.profilePic;
res.cookie("jwt", refreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
});
return res
.status(200)
.cookie("jwt", refreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
})
.json({ accessToken: accessToken, profilePic: profilePic })
.end();
} catch (err) {
console.log(err);
return res.status(err.statusCode).json(err.message).end();
}
});

This code segment implements the SignIn functionality in a Node.js application, handling user authentication securely. It first extracts the user’s credentials from the request body and retrieves the user from the database based on the provided email. If the user is not found or the password does not match, it throws custom errors indicating the corresponding issues. It then checks for an existing refresh token for the user and verifies its validity to prevent token reuse. Upon successful authentication, it calls a service function to generate new access and refresh tokens and retrieves the user’s profile picture. The new refresh token is set in an HTTP-only cookie for security, and the access token and profile picture are sent back in the response. Any errors encountered during the process are appropriately handled and returned as error responses with the corresponding status codes and messages.

const signIn = async (user, newRefreshTokenArray) => {
const token = user.generateAuthToken();
user.refreshtoken = [...newRefreshTokenArray, token.refreshtoken];
await user.save();
const data = {
token: token,
userId: user.id,
};
return data;
};

module.exports = {
signUp,
signIn,
};

This service function signIn handles the creation of authentication tokens and retrieval of user profile information upon successful user authentication. It takes the authenticated user object and an array of new refresh tokens as parameters. Then, it generates authentication tokens using the generateAuthToken method of the user object. The new refresh token is added to the user’s refreshtoken array, and the user object is saved back to the database. Finally, it constructs and returns an object containing the generated tokens and the user’s ID. This function ensures that authentication tokens are created and stored securely, and user information is retrieved and provided upon successful authentication.

Okay until here is a basic auth flow we just need the Logout functionality:

exports.logOut = tryCatch(async (req, res) => {
//on client, also delete the accessToken
const cookies = req.cookies;
if (!cookies?.jwt) return res.sendStatus(204);
const refreshToken = cookies.jwt;
//is refresh token in db?
const foundUser = await User.findOne({ refreshtoken: refreshToken });

if (!foundUser) {
res.clearCookie("jwt", { httpOnly: true });
return res.sendStatus(204);
}

//Delete refreshToken in db
foundUser.refreshtoken = foundUser.refreshtoken.filter(
(rt) => rt !== refreshToken
);
await foundUser.save();

res.clearCookie("jwt", { httpOnly: true });
res.sendStatus(204);
});

This code snippet defines the logOut controller function responsible for handling user logout requests in a Node.js application. It utilizes the tryCatch utility for error handling. The function first checks if the user has a refresh token stored in a cookie. If not, it sends a status code of 204 (No Content) to indicate that no action is required. If a refresh token is found, the function queries the database to verify its existence. If the refresh token is not found in the database, it clears the refresh token cookie and sends a status code of 204. Otherwise, it removes the refresh token from the user's stored refresh tokens, saves the updated user object back to the database, clears the refresh token cookie, and sends a status code of 204 to indicate successful logout. This function ensures proper handling of user logout requests by managing refresh tokens securely and clearing associated cookies.

The Refresh endpoint

I’m sure you recognized the refresh token in all functionality. We are not used yet always just sending or deleting. Let’s talk a little bit about the refresh token.

A refresh token is a security token used in authentication systems to obtain new access tokens once they expire. Unlike access tokens, which have a short lifespan, refresh tokens are long-lived and typically last for days or even weeks. When an access token expires, the refresh token can be used to request a new access token without requiring the user to log in again. This enhances security by minimizing the exposure of sensitive credentials and reducing the frequency of user authentication. Refresh tokens are securely stored on the client-side, often in HTTP-only cookies, and are exchanged for new access tokens through a secure authentication flow.

So, does that clarify things a bit? For enhance this we need a refresh token endpoint what we can call “silent” when the user access key is expired.

exports.refreshToken = tryCatch(async (req, res) => {
const cookies = req.cookies;
if (!cookies?.jwt) return res.sendStatus(401);
const refreshToken = cookies.jwt;
res.clearCookie("jwt", { httpOnly: true });
const foundUser = await User.findOne({ refreshtoken: refreshToken });

if (!foundUser) {
jwt.verify(
refreshToken,
process.env.REFRESH_SECRET_KEY,
async (err, decoded) => {
if (err) return res.sendStatus(403); //Forbidden
const hackedUser = await User.findOne({ username: decoded._id });
hackedUser.refreshtoken = [];
const result = await hackedUser.save();
}
);
return res.sendStatus(403);
}

const newRefreshTokenArray = foundUser.refreshtoken.filter(
(rt) => rt !== refreshToken
);

//evaluate jwt
jwt.verify(
refreshToken,
process.env.REFRESH_SECRET_KEY,
async (err, decoded) => {
if (err) {
foundUser.refreshtoken = [...newRefreshTokenArray];
const result = await foundUser.save();
}
if (err || foundUser._id.toString() !== decoded._id) {
return res.sendStatus(403);
}
//refreshtoken still valid
const accessToken = jwt.sign(
{ _id: decoded._id },
process.env.ACCESS_SECRET_KEY,
{ expiresIn: "10s" }
);

const newRefreshToken = jwt.sign(
{ _id: foundUser._id },
process.env.REFRESH_SECRET_KEY,
{ expiresIn: "1d" }
);
foundUser.refreshtoken = [...newRefreshTokenArray, newRefreshToken];
const result = await foundUser.save();
res.cookie("jwt", newRefreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.status(200).json(accessToken);
}
);
});
  1. Checking for Refresh Token: We start by checking if there’s a refresh token stored in a cookie named “jwt.” If not, we respond with a status code of 401 (Unauthorized), indicating that the user needs to authenticate.
  2. Clearing Cookie: Regardless of whether a refresh token is found, we clear the “jwt” cookie to ensure security.
  3. Finding User: We then search for the user associated with the refresh token in our database. If the user isn’t found, we proceed to verify the refresh token’s integrity.
  4. Verifying Refresh Token: Using a secret key stored in our environment variables, we verify the refresh token. If it’s invalid or expired, we respond with a status code of 403 (Forbidden). Additionally, we locate the user whose token was used for verification and remove all refresh tokens associated with them, preventing any potential misuse.
  5. Refreshing Tokens: Assuming the refresh token is valid and associated with a user, we proceed to refresh the access and refresh tokens. We generate a new access token with a short expiration time (here, 10 second) and a new refresh token with a longer expiration time (here, 1 day). We update the user’s refresh token array with the new refresh token and save the changes to the database.
  6. Setting New Cookie: Finally, we set the new refresh token as a cookie, ensuring it’s sent securely with HTTP-only flag and a maximum age of 24 hours. We respond with a status code of 200 (OK) along with the new access token.

We also need a secure endpoint in this case we get the user email from the database:

exports.getUserData = tryCatch(async (req, res) => {
const userId = req.params.id;
const foundUser = await User.findOne({ _id: userId });

const data = {
userEmail: foundUser.email,
};

res.status(200).json(data).end();
});

We also need to add to our routes:

const express = require("express");
const authController = require("./controllers");
const verifyJWT = require("../../middleware/verifyJWT");

const router = express.Router();

//Auth based routes
router.post("/signUp", authController.SignUp);
router.post("/signIn", authController.SignIn);
router.get("/refresh", authController.refreshToken);
router.get("/logout", authController.logOut);

router.get("/getUser/:id", verifyJWT, authController.getUserData);

module.exports = router;

Here I need to talk about the verifyJWT middleware:

const jwt = require("jsonwebtoken");
require("dotenv").config();

const verifyJWT = (req, res, next) => {
const authHeader = req.headers["authorization"];
if (!authHeader) return res.sendStatus(401);
console.log(authHeader);
const token = authHeader.split("Bearer ")[1];
console.log(token);
console.log("access secret", process.env.ACCESS_SECRET_KEY);
jwt.verify(token, process.env.ACCESS_SECRET_KEY, (err, decoded) => {
console.log(decoded);
if (err) {
console.log(err);
return res.status(403).json({ error: "Forbidden: JWT token expired!" });
}
req.user = decoded.username;
next();
});
};

module.exports = verifyJWT;

In this code, we’re defining a middleware function named verifyJWT, responsible for verifying JSON Web Tokens (JWT) used for authentication in our application. First, we import the jsonwebtoken package to handle JWT verification and the dotenv package to access environment variables securely. The verifyJWT function takes three parameters: req (request), res (response), and next, allowing it to operate as middleware in Express applications. Within the function, we extract the JWT token from the Authorization header of the incoming request. If the header is missing or malformed, we immediately respond with a status code of 401 (Unauthorized). Otherwise, we split the header to isolate the token from the “Bearer “ prefix. We then use jwt.verify to decode and verify the token against the access secret key stored in the environment variables. If the verification fails (e.g., due to token expiration or invalid signature), we respond with a status code of 403 (Forbidden) and an error message. Otherwise, if the token is valid, we extract the decoded payload (which typically contains user information) and attach it to the request object as req.user. Finally, we call the next function to pass control to the next middleware or route handler in the request-response cycle. This middleware ensures that only requests with valid JWT tokens are allowed to access protected routes, enhancing the security of our application.

After we created all things we need some aditional setup in our server.js for cors and cookie parsing:

const express = require("express");
const cors = require("cors");
const db = require("./dbconnection");
const port = 5000;
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const app = express();

const userRoutes = require("./apis/users/routes");

const frontendURI = "http://localhost:5173";

app.use(bodyParser.json({ limit: "5mb" }));
app.use(cookieParser());
app.use(
cors({
origin: frontendURI, // Replace with your frontend domain
credentials: true, // Allow credentials (cookies, authorization headers)
})
);
app.use("/users", userRoutes);
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
  1. cors (Cross-Origin Resource Sharing):
  • Cors middleware is responsible for enabling Cross-Origin Resource Sharing in our application. It allows resources on our server to be requested from another domain (origin) than the one it originated from. This is crucial for client-side web applications that make requests to servers on different domains.
  • In our code, we’re configuring cors with specific options:
  • origin: Specifies the allowed origin or origins of cross-origin requests. In this case, we're allowing requests from a specific frontend URI (frontendURI) which is "http://localhost:5173".
  • credentials: Indicates whether or not the server should include credentials such as cookies or authorization headers in cross-origin requests. Setting it to true allows the client to include such credentials.

2. bodyParser:

  • The bodyParser middleware parses incoming request bodies and makes the parsed data available under the req.body property. It's essential for processing data sent from clients, especially when dealing with POST or PUT requests.
  • In our code, we’re using bodyParser.json() to parse JSON-encoded request bodies. We’re also setting a limit of 5MB for the request body size to prevent potential denial-of-service attacks by limiting the amount of data that can be sent in a single request.

3. cookieParser:

  • CookieParser middleware parses cookies attached to the client’s request and makes them available under the req.cookies property. It's essential for handling cookies sent by the client, which are often used for session management, authentication, and other purposes.
  • In our code, we’re simply using the default settings of cookieParser without any additional configuration.

Now that we’ve covered the backend implementation, it’s time to tie it all together by creating a minimal frontend for our application. By building a frontend interface, we’ll be able to interact with the backend functionalities we’ve just developed. Let’s start by setting up a basic frontend structure using React. We’ll create user interface components to interact with the authentication endpoints we’ve implemented on the backend, such as SignUp, SignIn, RefreshToken, and LogOut. Additionally, we’ll utilize tools like Axios to make HTTP requests to our backend API and handle user authentication flows seamlessly. So let’s roll up our sleeves and dive into the frontend development process to complement our backend architecture!

If you want to verify and ensure that everything is done correctly, please visit my GitHub repository for this code.

Looking forward to seeing you in part 2!

--

--

Tókos Bence

Hi everyone! I'm an enthusiastic full-stack developer. Please feel free to reach out to me via email (tokosbex@gmail.com) or Twitter (@tokosbex).