Back

Token Based Authentication (Express & MongoDB)

Posted: Dec 8, 2020 (updated: Dec 8, 2020)

User authentication is a critical part of many web applications. Over the past two years, I've built a wide range of apps using different authentication systems. All this experimentation has led me to a two token system that is explained really well in this post. I'd highly recommend giving it a read as I won't be diving into the attacks and the vulnerabilities this authentication system prevents. As well, it does a great job of explaining the system and its security measures and also has an implementation for GraphQL.

High Level Overview

This authentication system uses two tokens: an access token and a refresh token.

The access token is a json web token (jwt) that contains user identifying data. This data is used by a route handler to authenticate the validity of the access token and the user. It gives the user access to protected endpoints. Access tokens are meant to be very short lived, usually expiring in no more than 15 minutes.

The refresh token is also a jwt and is used to request a new access token. The refresh token is saved as a cookie and persists through page reloads. The refresh token is valid for a much longer period of time. Refresh tokens can be valid for hours, days, weeks, or months depending on the application.

On the client side, we'll store the access token in memory to prevent XSS attacks from gaining access to it and use a refresh token to request new access tokens periodically to mitigate CRSF attacks.

I'd highly recommend you give the post a read as it contains more details into how this authentication prevents XSS and CRSF attacks.

Server

Server Boilerplate

Setup a simple express server

// \server\index.js
const express = require("express");
const bodyParser = require("body-parser");
const mongoose = require("mongoose");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const keys = require("./config/keys");

const PORT = process.env.PORT || 5000;
const app = express();

// Models
require("./models");

// Set body to parse incoming requets
app.use(
  bodyParser.urlencoded({
    extended: true,
  })
);
app.use(
  bodyParser.json({
    limit: "500mb",
  })
);
app.use(cookieParser());

// Connect to mongoose
mongoose
  .connect(keys.MONGO_URI, {
    useNewUrlParser: true,
    useFindAndModify: false,
    useCreateIndex: true,
    keepAlive: 1,
    useUnifiedTopology: true,
  })
  .then(() => {
    console.log("Successfully connected to mongo.");
  })
  .catch((err) => {
    console.log("Error connecting to mongo.", err);
  });

// Require routes
app.use(require("./routes"));

app.listen(PORT, () => {
  console.log("Listening on port: " + PORT);
}); // tell express to listen to the port

Create \config\keys.js, \middlewares\index.js, \models\index.js, \routes\index.js, and \routes\auth.js. The project directory should look like this:

\server
    \config
        keys.js
    \middlewares
        index.js
    \models
        index.js
    \node_modules
    \routes
        auth.js
        index.js
    index.js
    package-lock.json
    package.json

Add the database credentials and secrets to our keys file. The refresh token secret and access token secret can be any string. Add these to keys.js:

// \server\config\keys.js
module.exports = {
  MONGO_URI: "enter-your-mongo-uri",
  ACCESS_TOKEN_SECRET: "eokfbfnmfrgneogpropekfewnvnfzlkaeiqpgij",
  REFRESH_TOKEN_SECRET: "irgkorhpokotknotkint",
};

We don't have any middlewares to implement right now so we can leave \middlewares\index.js empty for now.

\models\index.js is responsible for requiring in our models, for now, we only have the one User model:

// \server\models\index.js
require("./User");

Implementation of the User schema. For this application, I'm only storing the username and password. Add any properties that you need. The pre middleware function hashes the password before it is saved into the database. With foresight, I'm creating generateAccessToken and generateRefreshToken which do exactly what the names suggest.

// \server\models\User.js
const mongoose = require("mongoose");
const bcryptjs = require("bcryptjs");
const jwt = require("jsonwebtoken");
const Schema = mongoose.Schema;
const keys = require("../config/keys");

const UserSchema = new Schema({
  username: String,
  password: String,
  tokenVersion: {
    type: Number,
    default: 0,
  },
});

UserSchema.pre("save", async function (next) {
  // Hash the password before saving the user model
  const user = this;
  if (user.isModified("password")) {
    user.password = await bcryptjs.hash(user.password, 8);
  }
  next();
});

UserSchema.methods.generateAccessToken = function () {
  const user = this;
  const accessToken = jwt.sign({ ID: user._id }, keys.ACCESS_TOKEN_SECRET, {
    expiresIn: "15s",
  });
  return accessToken;
};

UserSchema.methods.generateRefreshToken = function () {
  const user = this;
  const tokenVersion = user.tokenVersion || 0;
  const refreshToken = jwt.sign(
    { ID: user._id, tokenVersion },
    keys.REFRESH_TOKEN_SECRET,
    {
      expiresIn: "7d",
    }
  );
  return refreshToken;
};

module.exports = mongoose.model("User", UserSchema);

Finally, let's setup routing for our express app. Inside \routes\index.js:

// \server\routes\index.js
const express = require("express");
const router = express.Router();

router.use("/api/auth", require("./auth"));

module.exports = router;

And inside \routes\auth.js:

// \server\routes\auth.js
const express = require("express");
const router = express.Router();
const mongoose = require("mongoose");
const User = mongoose.model("User");
const jwt = require("jsonwebtoken");
const keys = require("../config/keys");

module.exports = router;

Implementing Authentication Endpoints

Here's are the endpoints we need to implement:

  • signup
  • login
  • refresh-token (given a refresh token, generate and send an access token)
  • logout
  • logout-all (makes all refresh tokens invalid)
  • whoami (given an access token, returns user data)

Sign up

Let's first implement the sign up endpoint which will be a POST request to /api/auth/signup. These are the key steps to registering a new user:

  1. Create and save the user
  2. Generate an access token with the user's ID
  3. Generate a refresh token with the user's ID and token version
  4. Set the refresh token as a cookie to be sent along with the response
  5. Send the access token (and the user if you want)

Shown below is the signup endpoint. Note that the snippet below does not show auth.js in its entirety.

// \server\routes\auth.js

// ...

// Adjust cookie options depending on environment
const cookieOptions =
  process.env.NODE_ENV === "production"
    ? {
        httpOnly: true,
        secure: true,
        sameSite: "None",
      }
    : {
        httpOnly: false,
      };

router.post("/signup", async (req, res) => {
  const { username, password } = req.body;
  // Check that we have all the required information, more validation can be added
  if (!email || !password) {
    return res.status(422).send({ message: "Missing fields." });
  }
  try {
    // Check if the user already exists or if the email is in use
    const existingUser = await User.find({ email }).exec();
    if (existingUser) {
      return res.status(409).send({ message: "Email is already in use." });
    }

    // Step 1 - Create and save the user
    const user = await new User({
      username: username,
      password: password,
      tokenVersion: 0,
    }).save(); // The pre method will run and hash the password before saving

    // Step 2 - Generate access token
    const accessToken = user.generateAccessToken();

    // Step 3 - Generate refresh token
    const refreshToken = user.generateRefreshToken();

    // Step 4 - Send the refresh token
    sendRefreshToken(res, refreshToken);

    // Step 5 - Send the access token (and user)
    return res.status(201).send({ user, accessToken });
  } catch (err) {
    console.log(err.message);
    return res.status(500).send(err);
  }
});
// ...

Notes:

  • I've set it so that access tokens expire in 15 minutes and refresh tokens expire in seven days. For security reasons, we want to keep the access token short-lived.
  • I decided to call my cookie "app_rtoken" (application refresh token); you can name it whatever you'd like.
  • Returning the user object is optional.

Before we move on, let's test our endpoint to see if it works. Using Postman, make a POST request to localhost:5000/api/auth/signup.

We're successfully getting back the access token and the user after we sign up: Screenshot of a successful sign up using Postman

And the token is also being sent over: Screenshot showing that the refresh token was also sent.

Log in

We'll follow similar steps to log in a user.

  1. Check if the username exists
  2. Compare the user entered password with the hashed password saved in the database
  3. Generate an access token with the user's ID
  4. Generate a refresh token with the user's ID and token version
  5. Set the refresh token as a cookie to be sent along with the response
  6. Send the access token (and user if you want)

Translated to code:

// \server\routes\auth.js

// ...

router.post("/login", async (req, res) => {
  const { username, password } = req.body;
  // Step 1 - Check if the username exists
  try {
    const user = await User.findOne({ username }).exec();
    if (!user) {
      return res.status(404).send({ message: "User does not exist." });
    }

    // Step 2 - Compare passwords
    const isPasswordMatch = await bcryptjs.compare(password, user.password);
    if (!isPasswordMatch) {
      return res.status(401).send({ message: "Incorrect password" });
    }

    // Step 3 - Generate access token
    const accessToken = await user.generateAccessToken();

    // Step 4 - Generate refresh token
    const refreshToken = await user.generateRefreshToken();

    // Step 5 - Set refresh token
    sendRefreshToken(res, refreshToken);

    // Step 6 - Send access token
    return res.send({ user, accessToken });
  } catch (err) {
    console.log(err.message);
    return res.status(500).send({ err: err.message });
  }
});

// ...

Let's use Postman to check that our login endpoint works. If you tested your signup API with Postman, you'll likely have a saved cookie already. To remove this cookie, click "cookies" beside the URL bar and delete the cookie. Screenshot showing how to remove cookies in Postman

A successful login should result in a response that includes the user data, an access token, and a cookie being set. Screenshot of a successful log in using Postman

Refresh Token

Now that we have the log in and sign up endpoints completed, we need to implement the endpoint that exchanges a refresh token (sent to the server as a cookie) for an access token. This endpoint is the piece that allows the client application to safely persist the user's authenticated state.

Steps:

  1. Check that a refresh token was sent with the request as a cookie
  2. Verify that the token is valid and was signed by our server
  3. Fetch the user from the database
  4. Compare the token version of the user to the token version of the refresh token
  5. Generate and send a new access token and refresh token

Translated to code:

// \server\routes\auth.js

// ...

router.post("/refresh-token", async (req, res) => {
  // Step 1
  const token = req.cookies.app_rtoken;
  if (!token) {
    return res.status(401).send({ accessToken: "" });
  }
  // Step 2
  try {
    const payload = jwt.verify(token, keys.REFRESH_TOKEN_SECRET);

    // Step 3
    const user = await User.findById(payload.ID).exec();
    if (!user) {
      return res.status(401).send({ accessToken: "" });
    }
    // Step 4
    if (user.tokenVersion !== payload.tokenVersion) {
      return res.status(401).send({ accessToken: "" });
    }
    // Step 5
    let accessToken = await user.generateAccessToken();
    let refreshToken = await user.generateRefreshToken();
    sendRefreshToken(res, refreshToken);

    return res.status(201).send({ accessToken });
  } catch (err) {
    // Handle different error codes
    console.log(err.message);
    return res.status(500).send({ accessToken: "" });
  }
});

// ...

Test with Postman

First, make a request to localhost:5000/api/auth/refresh-token with a valid refresh token set as a cookie. Because we've just logged in with Postman when testing the log in endpoint, we should have a cookie set in Postman already. Here's the result of a POST request to the refresh-token endpoint:

Now clear the cookies and make a POST request to localhost:5000/api/auth. You should get the following response:

{
  "accessToken": ""
}

Log out

To log a user out, the cookie needs to be cleared.

// \server\routes\auth.js

// ...

router.post("/logout", (req, res) => {
  res.clearCookie("app_rtoken", cookieOptions);
  return res.status(200).send();
});

// ...

Log out of all devices (invalidate all refresh tokens)

Logic in the refresh-token endpoint compares the token version of the JWT and the user. To invalidate all issued refresh tokens, increment the user's token version.

// \server\routes\auth.js

// ...

router.post("/logout-all", async (req, res) => {
  const token = req.cookies.app_rtoken;
  if (!token) {
    return res.status(401).send({ message: "No token" });
  }

  let payload = null;
  try {
    payload = jwt.verify(token, keys.REFRESH_TOKEN_SECRET);
  } catch (err) {
    console.log(err.message);
    return res.status(401).send({ message: "Invalid token" });
  }

  try {
    const user = await User.findByIdAndUpdate(payload.ID, {
      $inc: {
        tokenVersion: 1,
      },
    }).exec();

    // Logging updated token version for sanity check
    console.log("User's token version is now: " + user.tokenVersion);
    return res.status(200).send();
  } catch (err) {
    console.log(err.message);
    return res.status(500).send();
  }
});

// ...

Incrementing the token version invalidates issued refresh tokens. This means that currently active access tokens are still valid and will enable the user to access protected endpoints. This is why it is recommended to expire access tokens after a short period of time.

Concretely, in this example, after making a request to /api/auth/logout-all, a client with a previously issued access token can still access authentication required endpoints for 15 minutes (the expiry time of the access token). We'll see how to destroy the access token on the client side.

Authentication Middleware

Let's write a function that checks for the access token so that we can protect endpoints behind authentication. This middleware has three steps:

  1. Check for an access token sent in the request header
  2. Verify that the access token is valid
  3. Attach the jwt's encoded data to the request object

We'll write the middleware inside \middlewares\index.js:

// \server\middlewares\index.js
const jwt = require("jsonwebtoken");
const keys = require("../config/keys");

module.exports = {
  auth: async (req, res, next) => {
    // Step 1
    const authorization = req.headers["authorization"];
    if (!authorization) {
      return res.status(401).send();
    }
    // Step 2
    try {
      const token = authorization.split(" ")[1];
      const payload = jwt.verify(token, keys.ACCESS_TOKEN_SECRET);
      // Step 3
      req.payload = payload;
      next();
    } catch (err) {
      // Add error handling
      console.log(err.message);
      return res.status(401).send();
    }
  },
  // Any other middlewares
};

Protected Routes

We can protect specific endpoints behind authentication by importing the auth middleware. Inside \routes\auth.js, import the middleware:

// \server\routes\auth.js

// ...

import middlewares from "../middlewares";

// ...

and add the following route handler:

// \server\routes\auth.js

// ...

router.get("/protected", middlewares.auth, (req, res) => {
  res.send("You've reached the protected endpoint.");
});

router.get("/whoami", middlewares.auth, async (req, res) => {
  try {
    const { ID } = req.payload;
    const user = await User.findById(ID).exec();
    return res.status(200).send({ user });
  } catch (err) {
    return res.status(500).send(err);
  }
});

// ...

Full Test

First, let's log in:

Copy the access token and paste it as the value to the token value. Make the get request:

Invalidate the access token (add some letters at the end) and make the request again, you will see unauthorized (401).

That does it for the server portion. Moving onto the client implementation.

Client

Authentication strategies based around JWT's relies on the client to store information. We need logic to maintain the access token and refresh token. As well, we'll need logic to seamlessly refresh to access token when it has expired.

I am using create-react-app for demonstration purposes. The concepts will apply to other configurations of React and other frontend frameworks (Vue, Svelte, etc.) as well.

Storing the Access Token

Create accessToken.js in the \src directory which will handle saving the access token to memory:

// \client\src\accessToken.js
let accessToken = "";

export function setAccessToken(token) {
  accessToken = token;
}

export function getAccessToken() {
  return accessToken;
}

Refreshing the Access Token

There are three possible outcomes when a client requests a protected endpoint:

  1. Client has valid access token. Can successfully access the endpoint.
  2. Client does not have a valid access token (expired, does not exist, malformed, etc.) but has a refresh token. Attempt to exchange the refresh token for an access token and retry the original request.
  3. Client does not have a valid access token nor a valid refresh token. Cannot access the protected endpoint.

Naturally, two questions arise:

  1. How do we attach the access token to every request?
  2. How do we attmept to exchange the refresh token for an access token if it fails the first time? (Scenario 2)

The answer: Axios Interceptors

We'll create an interceptor that appends the access token to all requests and an interceptor that initiates the token exchange logic on 401 responses.

The HTTP 401 Unauthorized client error status response code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource.

Modify index.js:

// \client\src\index.js
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import axios from "axios";
import { getAccessToken, setAccessToken } from "./accessToken";

// Send cookies with all requests
axios.defaults.withCredentials = true

// Executes on every request
axios.interceptors.request.use(
  async function (config) {
    // Do something before request is sent
    let accessToken = getAccessToken();
    config.headers["authorization"] = `Bearer ${accessToken}`;
    return config;
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error);
  }
);

// Executes on every response
axios.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    const originalRequest = error.config;

    if (
      error.response.status === 401 &&
      originalRequest.url.includes("/api/auth/refresh-token")
    ) {
      // The failed response is from the refresh token endpoint, do not retry
      return Promise.reject(error);
    }

    // Failed authentication and have not yet retried this request
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      // Attempt to refresh token
      return axios
        .post(`/api/auth/refresh-token`)
        .then((res) => {
          setAccessToken(res.data.accessToken);
          // Retry the original request
          return axios(originalRequest);
        });
    }

    // Return the error if we have already retried the original request
    return Promise.reject(error);
  }
);

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

The request interceptor gets the access token from memory and appends it to the header as a Bearer token. If the client has not yet been authorized or has reloaded the page, then the value of the accessToken will be an empty string and will fail our auth middleware resulting in a 401 responsee.

The response interceptor will make a request to the refresh token endpoint if the initial request failed with a 401 error. Additional logic is added to prevent an infinite loop from occurring.

Protecting Client Routes

It is very common to protect client routes (dashboards, settings, profile, etc.). This wrapper component will ensure that the client is authenticated before redirecting to the request route.

import { useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import axios from 'axios'

const Status = {
    LOADING: 'LOADING',
    SUCCESS: 'SUCCESS',
}

export default function Protector({ children }) {
    const history = useHistory()
    const [status, setStatus] = useState(Status.LOADING)

    useEffect(() => {
        axios
            .get(`/api/auth/whoami`)
            .then((response) => {
                const { data } = response
                const { user } = data
                if (!user) {
                    history.push("/login")
                } else {
                    setStatus(Status.SUCCESS)
                }
            })
            .catch(() => {
                history.push("/login")
            })
    }, [history])

    if (status === Status.LOADING) {
        // Add a fancy loading UI
        return 'Loading'
    }
    // Client is authenticated, show the page contents
    return children
}

Practical Implications

What is the impact of the access token expiry time?

A shorter expiry time means our access token will be invalid more often. However, all 401 responses will automatically trigger an attempt to refresh the access token. In practice, shorter expiry times on the access token result in more frequent requests to /api/auth/refresh-token.

When does a user need to re-authenticate?

A a user only needs to re-authenticate when the refresh token has expired. However, because we are generating a new refresh token each time we request a new access token, this continually slides the inactivity window. In practice, users only need to re-authenticate if they have not visited the site for more than the refresh token expiry time.

Test the Authentication Flow

Step 1: Set an access token expiration time to 15s (something very short)

Set the expiration time for access tokens to 15s (something very short)

Step 2: Log out

Access token and cookies are cleared.

Step 3: Visit /protected

Without an access token, the request to /api/auth/protected fails with a 401 unauthorized error. This error is caught by the axios response interceptor and a request to /api/auth/protected is made. This request also fails as no refresh token was supplied. An error is returned.

Step 4: Login

A successful login returns an access token and sets the refresh token as a cookie Step 5.

Visit /protected: We have a valid access token so we can see the protected message

Step 6: Visit /

Visit the home page and wait for more than 15s (or whatever you set the refresh expiry time to). Use the navigation menu links to do this as entering a new URL will cause the App.js component to re-render, running the useEffect hook and refreshing the access token. The access token is now expired.

Step 7: Visit /protected

A request is made to get the protected content but fails with a 401 unauthorized error because the access token has expired. This error is caught by the axios response interceptor and a new request is made to /auth/refresh-token to retrieve new access and refresh tokens. The original request to /api/auth/protected is tried again with the new access token. The new access token is valid and the server serves the protected content for the client to render.

We now have a working authentication system using json web tokens that allows our client application, our authentication server, and other API servers to be split across different domains while ensuring we are safe from XSS and CRSF attacks. Further, we've managed to persist the user's log in between page reloads.