Building a Secure Authentication System with React.js and Express.js: In-Memory Access Tokens and HTTP-Only Cookies for Refresh Tokens

Building a Secure Authentication System with React.js and Express.js: In-Memory Access Tokens and HTTP-Only Cookies for Refresh Tokens

Introduction

Let's discuss a common worry related to storing access tokens. Storing them in local storage or cookies can make your system vulnerable to Cross-Site Scripting (XSS) attacks, which is a significant security risk. However, in this article, we'll explore the creation of an authentication system using React and Express. The unique aspect here is that we'll be securely storing access tokens in memory instead. If you're concerned about persistence, don't worry, I'll address those concerns as we proceed.

Note: In this article, I'll offer a high level explanation of the processes involved. If you're looking for the full source code and understand how to structure the project, you can visit the GitHub repository linked at the end.

Backend setup

Importing dependencies & initializing express app

import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Schema, model, connect } from "mongoose";

const app = express();

Secret Keys and Database URI

const DATABASE_URI = "mongodb://127.0.0.1:27017/auth_demo_db";
const REFRESH_TOKEN_SECRET = "2c9020d44b2719c4687a30e7f44f3bd3707";
const ACCESS_TOKEN_SECRET = "df5c88d863a00a2b7b775404f2cb6e5b216";
  • DATABASE_URI: This is the connection string used to link to the MongoDB database.

  • REFRESH_TOKEN_SECRET: This secret key is utilized for creating and verifying refresh tokens.

  • ACCESS_TOKEN_SECRET: This secret key is utilized for creating and verifying access tokens.

Middlewares setup

app.use(express.json());
app.use(cookieParser());
const allowedOrigins = ["http://localhost:5173", "http://localhost:3000"];
app.use(
  cors({
    origin: (origin, callback) => {
      if (allowedOrigins.indexOf(origin) !== -1 || !origin)
        callback(null, true);
      else callback(new Error("Origin not allowed by CORS"));
    },
    methods: "GET,POST,PUT,DELETE",
    credentials: true,
    optionsSuccessStatus: 204,
  })
);
  • express.json(): Middleware to parse incoming JSON requests.

  • cookieParser(): Middleware to parse cookies from incoming requests.

  • allowedOrigins: Array of allowed frontend origins.

  • cors(): Configured with options to handle CORS.

    • origin: Callback to determine if the origin is allowed.

    • methods: Allowed HTTP methods.

    • credentials: Enable credentials (cookies, HTTP authentication).

    • optionsSuccessStatus: HTTP status code for successful CORS preflight requests.

User model setup

const User = model(
  "User",
  new Schema({
    email: { type: String, required: true, unique: true },
    password: { type: String, required: true },
    refreshTokens: { type: [String], required: true },
  })
);
  • Define a MongoDB schema for the User model with email, password, and refreshTokens fields.

Database Connection

(async () => {
  try {
    await connect(DATABASE_URI);
    console.log("Database connected");
  } catch (error) {
    console.log("Database not connected", error.message);
    process.exit(1);
  }
})();
  • Asynchronous IIFE to connect to the MongoDB database.

Defining Routes

Signup route /api/signup

// Signup route
app.post("/api/signup", async (req, res) => {
  try {
    const { email, password } = req.body;
    if (!(email && password))
      return res
        .status(400)
        .json({ message: "email and password are required" });

    const existingUser = await User.findOne({ email });
    if (existingUser)
      return res.status(400).json({ message: "Email already exists" });

    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);
    const newUser = new User({ email, password: hashedPassword });
    await newUser.save();

    res.status(201).json({ message: "Account Created" });
  } catch (error) {
    console.log(error);
    res.sendStatus(500);
  }
});
  • Handle POST requests to "/api/signup" for user registration.

  • Validate the presence of email and password in the request body.

  • Check if the email already exists in the database.

  • Generate a salt and hash the password using bcrypt.

  • Create a new user instance and save it to the database.

  • Respond with a status of 201 if successful.

Login route /api/login

// Login route
app.post("/api/login", async (req, res) => {
  try {
    const { email, password } = req.body;
    if (!(email && password))
      return res
        .status(400)
        .json({ message: "email and password are required" });

    const user = await User.findOne({ email });
    if (!user) return res.sendStatus(401);

    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) return res.sendStatus(401);

    const accessToken = jwt.sign({ _id: user._id }, ACCESS_TOKEN_SECRET, {
      expiresIn: "15m",
    });
    const refreshToken = jwt.sign({ _id: user._id }, REFRESH_TOKEN_SECRET);

    await User.updateOne(
      { _id: user._id },
      { $push: { refreshTokens: refreshToken } }
    );

    res.cookie("refreshToken", refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: "None",
      maxAge: 5 * 24 * 60 * 60 * 1000,
    });

    res.json({ accessToken });
  } catch (error) {
    res.sendStatus(500);
  }
});
  • Handle POST requests to /api/login for user login.

  • Validate the presence of email and password in the request body.

  • Check if the user exists and validate the password.

  • Generate an access token and a refresh token.

  • Add the refresh token to the user's database record.

  • Set a cookie with the refresh token for subsequent requests.

  • Respond with the access token.

Refresh-token route /api/refresh

// Refresh-token route
app.post("/api/refresh", async (req, res) => {
  try {
    const refreshToken = req.cookies?.refreshToken;

    if (!refreshToken) return res.sendStatus(204);

    const user = await User.findOne({ refreshTokens: { $in: [refreshToken] } });

    if (!user) return res.sendStatus(403);

    jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (error, decoded) => {
      if (error || !user._id.equals(decoded._id)) return res.sendStatus(403);

      const accessToken = jwt.sign({ _id: user._id }, ACCESS_TOKEN_SECRET, {
        expiresIn: "15m",
      });

      res.json({ accessToken });
    });
  } catch (error) {
    res.sendStatus(500);
  }
});
  • Handle POST requests to /api/refresh for refreshing the access token.

  • Extract the refresh token from the request cookies.

  • If no refresh token is present, send a No Content (204) response.

  • Find a user with the specified refresh token in the database.

  • If no user is found, send a Forbidden (403) response.

  • Verify the refresh token against the secret key.

  • If the refresh token is valid, generate a new access token and respond with it.

Logout route /api/logout

app.post("/api/logout", async (req, res) => {
  try {
    const refreshToken = req.cookies?.refreshToken;

    if (!refreshToken) return res.sendStatus(204);

    const user = await User.findOne({ refreshTokens: { $in: [refreshToken] } });

    if (!user) {
      res.clearCookie("refreshToken", {
        httpOnly: true,
        secure: true,
        sameSite: "None",
      });
      return res.sendStatus(204);
    }

    await User.findOneAndUpdate(
      { _id: user._id },
      { $pull: { refreshTokens: refreshToken } },
      { new: true }
    );

    res.clearCookie("refreshToken", {
      httpOnly: true,
      secure: true,
      sameSite: "None",
    });
    res.sendStatus(200);
  } catch (error) {
    res.sendStatus(500);
  }
});
  • Handle POST requests to /api/logout for user logout.

  • Extract the refresh token from the request cookies.

  • If no refresh token is present, send a No Content (204) response.

  • Find a user with the specified refresh token in the database.

  • If no user is found, clear the cookie and send a No Content (204) response.

  • Remove the refresh token from the user's database record.

  • Clear the refresh token cookie and respond with a status of 200.

Verify Access-token Middleware

app.use((req, res, next) => {
  try {
    const authHeader = req.header("authorization");

    if (!authHeader) return res.sendStatus(401);

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

    jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
      if (err) return res.sendStatus(401);

      req.user = decoded;
      next();
    });
  } catch (error) {
    res.sendStatus(500);
  }
});
  • Middleware to verify and validity the JWT access token.

  • Extract the 'authorization' header from the request.

  • If 'authorization' header is not present, send a Unauthorized (401) response.

  • Extract the token from the 'authorization' header.

  • Verify the token against the secret key.

  • If the token is invalid, send a Unauthorized (401) response.

  • If the token is valid, attach the decoded user information to the request object and pass control to the next route handler.

Protected Route /api/protected

app.get("/api/protected", async (req, res) => {
  const userID = req.user._id;

  const data = await User.findById(userID).select({ _id: 0, email: 1 });

  res.json({ message: `Logged in as ${data.email}` });
});
  • Example of a protected route, accessible only with a valid access token.

  • Extract the user ID from the authenticated user object in the request.

  • Find the user by ID and select the 'email' field.

  • Respond with a JSON message with the user's email.

Server Listening

// Start the Express server on port 8000.
app.listen(8000, () => console.log(`Server running on port 8000`));

Frontend Setup

Create a basic react app using create-react-app or vite(recommended)

Importing dependencies

import { useEffect, useState, useContext, createContext, useRef } from "react";
import {
  Link,
  Outlet,
  Navigate,
  useNavigate,
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import Axios from "axios";

Initialization and Configuration

const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:8000"; // process.env.REACT_APP_SERVER_URL
const axios = Axios.create({
  baseURL: SERVER_URL,
  withCredentials: true,
});
  • Defines the backend server URL using environment variables.

  • Creates an Axios instance configured with the base URL and enables credentials for cross-origin requests.

  • SERVER_URL Constant: Defines a constant variable SERVER_URL using the nullish coalescing operator, taking the value from environment variable if defined, or defaulting to localhost:8000.

  • Axios Configuration: Creates an Axios instance named axios with a base URL set to SERVER_URL and the withCredentials option set to true, allowing the inclusion of credentials in cross-site HTTP requests.

Router Setup

const router = createBrowserRouter([
  // Protected Pages (Eg: dashboard, profile)
  {
    element: <ProtectedPages />,
    children: [{ path: "/dashboard", element: <Dashboard /> }],
  },
  // Authentication Pages (Eg: login, signup)
  {
    element: <AuthPages />,
    children: [
      { path: "/login", element: <Login /> },
      { path: "/signup", element: <Signup /> },
    ],
  },
  //Public
  { path: "/", element: <LandingPage /> },
]);
  • Sets up React Router routes for public, protected, and authentication pages.

App Component

const AuthContext = createContext(null);
const useAuth = () => useContext(AuthContext);

export default function App() {
  const [auth, setAuth] = useState({});

  useEffect(() => {
    const axiosRequestInterceptor = axios.interceptors.request.use(
      async (config) => {
        if (auth?.accessToken) {
          config.headers.Authorization = `Bearer ${auth.accessToken}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );
    return () => axios.interceptors.request.eject(axiosRequestInterceptor);
  }, [auth]);

  return (
    <AuthContext.Provider value={{ auth, setAuth }}>
      <RouterProvider router={router} />
    </AuthContext.Provider>
  );
}
  • Initialization: Sets up the AuthContext for managing the authentication state.

  • Axios Interceptor: Configures an Axios interceptor within the useEffect hook to include the access token in requests. This ensures that the access token is automatically added to the headers of outgoing requests when available.

  • Context Provider: Provides the authentication context to the app using <AuthContext.Provider>. The entire app is wrapped within this provider, making the authentication state available to all components nested within.

  • Router Rendering: Uses the RouterProvider from React Router to render the app's routes.

Hook to refresh the access token useRefreshToken

function useRefreshToken() {
  const { setAuth } = useAuth();
  return async () => {
    try {
      const { data } = await axios.post(
        `${SERVER_URL}/api/refresh`,
        {},
        { withCredentials: true }
      );
      const accessToken = data?.accessToken;
      setAuth(accessToken ? { accessToken } : {});
      return accessToken;
    } catch (error) {
      console.log(error);
    }
  };
}
  • Custom hook for refreshing the access token.

  • Utilizes the setAuth function from the authentication context.

LandingPage

function LandingPage() {
  return (
    <>
      <h1>Landing Page (Public Page)</h1>
      <br />
      <Link to="/login">Login</Link>
      <br />
      <Link to="/signup">Signup</Link>
    </>
  );
}
  • This component serves as a simple illustration of a public page, commonly used for introductory or informational purposes on a website and they have nothing to do with authentication. For Eg: about us or landing page

AuthPages (Wrapper component for authentication pages)

function AuthPages() {
  const [isLoading, setIsLoading] = useState(true);
  const { auth, setAuth } = useAuth();
  const refresh = useRefreshToken();

  useEffect(() => {
    const verifyRefreshToken = async () => {
      try {
        await refresh();
      } catch (error) {
        console.error("Error refreshing token:", error.message ?? error);
      } finally {
        setIsLoading(false);
      }
    };

    !auth.accessToken ? verifyRefreshToken() : setIsLoading(false);
  }, [auth.accessToken]);

  if (isLoading) return <h1>Loading...</h1>;
  if (auth.accessToken) return <Navigate to="/dashboard" replace />;

  return (
    <>
      <p>
        Navigate to <Link to="/">Landing Page</Link>
      </p>
      <Outlet />
    </>
  );
}
  • Manages the authentication flow for auth pages(Pages that don't require user to be logged in). Eg: login, signup, etc.

  • Checks if the user is authenticated using the auth state from the AuthContext.

  • Uses the useRefreshToken hook to refresh the access token if necessary.

  • Renders a loading message while the authentication state is being determined.

  • Renders the pages(login, signup) if the user is not authenticated, otherwise redirects to the dashboard.

ProtectedPages (Wrapper component for protected pages)

function ProtectedPages() {
  const [isLoading, setIsLoading] = useState(true);
  const { auth } = useAuth();
  const timeoutId = useRef(null);
  const refresh = useRefreshToken();

  useEffect(() => {
    const verifyAndRefresh = async () => {
      try {
        await refresh();
      } catch (error) {
        console.error("Error refreshing token:", error.message ?? error);
      } finally {
        setIsLoading(false);
        const nextRefreshTimeout = 28 * 60 * 1000;
        timeoutId.current = setTimeout(verifyAndRefresh, nextRefreshTimeout);
      }
    };

    verifyAndRefresh();
    return () => clearTimeout(timeoutId.current);
  }, []);

  if (isLoading) return <h1>Loading...</h1>;

  if (!auth.accessToken) return <Navigate to="/login" replace />;

  return <Outlet />;
}
  • Manages the authentication flow for pages requiring authentication.

  • Initializes loading state and a ref for timeout ID.

  • Uses useAuth to access authentication information.

  • Utilizes useRefreshToken hook to get a function for refreshing the access token.

  • The useEffect hook runs on component mount.

  • Attempts to refresh the token and logs errors if it fails.

  • Sets loading state to false after attempting refresh.

  • Sets a timeout for the next silent refresh after 28 minutes.

  • Cleanup function clears the timeout on component unmount.

  • Renders a loading message while the authentication state is being determined.

  • Redirects to login if not authenticated.

  • Renders child components (protected pages) if authenticated.

Login (Child of AuthPages component)

function Login() {
  const [credentials, setCredentials] = useState({
    email: "",
    password: "",
  });
  const navigate = useNavigate();
  const { setAuth } = useAuth();

  const handleChange = (e) => {
    const field = e.target.name;
    const value = e.target.value;
    setCredentials((prev) => ({ ...prev, [field]: value }));
  };

  const handleLogin = async (e) => {
    e.preventDefault();
    const isValid = Object.values(credentials).every(
      (value) => value != null && value !== ""
    );

    if (!isValid) return;
    const { data, status } = await axios.post(`/api/login`, credentials);

    if (status === 200 && data?.accessToken) {
      console.log(data);
      setAuth({ accessToken: data?.accessToken });
      navigate("/dashboard");
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <h1>Login</h1>
      <input
        type="email"
        name="email"
        onChange={handleChange}
        value={credentials.email}
        placeholder="email"
        required
      />
      <br />
      <input
        type="password"
        name="password"
        onChange={handleChange}
        value={credentials.password}
        placeholder="password"
        required
      />
      <br />
      <button>Login</button>
      <p>
        Don't have an account?
        <Link to="/signup">Signup</Link>
      </p>
    </form>
  );
}
  • Displays a login form with email and password input fields.

  • Uses the axios instance to make a login API request.

  • If the request is successful, sets the access token in the AuthContext and navigates to the dashboard.

Signup (Child of AuthPages component)

function Signup() {
  const [data, setData] = useState({
    email: "",
    password: "",
    confirmPassword: "",
  });
  const navigate = useNavigate();

  const handleChange = (e) => {
    const field = e.target.name;
    const value = e.target.value;
    setData((prev) => ({ ...prev, [field]: value }));
  };

  const handleSignup = async (e) => {
    e.preventDefault();
    const isValid =
      Object.values(data).every((value) => value != null || value !== "") &&
      data.password === data.confirmPassword;
    if (!isValid) return;
    const res = await axios.post(`/api/signup`, data);
    if (res.status === 201) navigate("/login");
  };
  return (
    <form onSubmit={handleSignup}>
      <h1>Signup</h1>
      <input
        type="email"
        name="email"
        placeholder="email"
        onChange={handleChange}
        value={data.email}
        required
      />
      <br />
      <input
        type="password"
        name="password"
        placeholder="password"
        onChange={handleChange}
        value={data.password}
        required
      />
      <br />
      <input
        type="password"
        name="confirmPassword"
        placeholder="re-enter password"
        onChange={handleChange}
        value={data.confirmPassword}
        required
      />
      <br />
      <button>Signup</button>
      <p>
        Have an account?
        <Link to="/login">Login</Link>
      </p>
    </form>
  );
}
  • Displays a signup form with email, password, and confirm password input fields.

  • Uses the axios instance to make a signup API request.

  • If the request is successful (status 201), navigates to the login page.

Dashboard Page (Child of ProtectedPages component)

function Dashboard() {
  const { setAuth } = useAuth();
  const messageRef = useRef(null);
  const navigate = useNavigate();

  useEffect(() => {
    (async () => {
      try {
        const { data, status } = await axios.get(`/api/protected`);
        if (status === 200 && data?.message) {
          messageRef.current.textContent = data.message;
        }
      } catch (err) {}
    })();
  }, []);

  const handleLogout = async () => {
    await axios.post("/api/logout");
    setAuth({});
    navigate("/login");
  };

  return (
    <>
      <h1>Dashboard (Protected Page)</h1>
      <p ref={messageRef}></p>
      <button onClick={handleLogout}>Logout</button>
    </>
  );
}
  • This component acts as a basic example of a protected page, meaning it's accessible only to logged-in users. Examples include the dashboard or profile pages.

  • It sends a request to a protected API endpoint, showcasing the functionality of the Protected Page.

  • Utilizes the axios instance for logging out through an API request.

  • Upon logout, it clears the authentication context and directs the user to the login page.

Demo

Conclusion

In conclusion, this tutorial provides a comprehensive guide to setting up a full-stack application with authentication using React and Express, along with JSON Web Tokens (JWTs). We covered the backend setup, including middleware configuration, user model definition, database connection, and routes for user signup, login, token refresh, and logout. On the frontend side, we explored how to structure the React app with routes, authentication context, and pages for public, protected, and authentication-specific functionalities.

This article serves as a foundational guide, and developers can extend and customize the application based on their specific requirements. It covers essential concepts for building a secure, scalable, and well-organized full-stack application with React and Express. Further exploration and customization of this project will provide valuable insights into real-world application development.

Key takeaways:

  • Users can sign up for an account and securely log in with their credentials.

  • Access tokens are generated upon login and refreshed silently to maintain a persistant and authenticated session.

  • Protected routes are accessible only with a valid access token.

  • Users can log out, clearing their session both on the frontend and backend.

Feel free to check out the GitHub repository for the complete source code and additional documentation. Happy coding!