React, NodeJS and JWT Authentication - the right way!

React, NodeJS and JWT Authentication - the right way!

·

14 min read

In this series of posts, we will create a secured end-to-end JWT-based authentication mechanism using NodeJS, Express, PassportJS and React. There's a lot of information online about JWT-based authentication, however, I still see a lot of questions and overall confusion around this topic when it comes to actual implementation in a project. Implementing a naive user authentication using JWT is relatively easy, but creating a safe and secure mechanism requires a little bit of attention.

In this series I will cover:

What and Why

JWT, or in its full name JSON Web Token, is an open standard defined by RFC7519 for safe and compact communication between parties. The authentication (and sometimes authorization) data is transmitted in a form of a JSON object. The payload inside JWT is digitally signed using a secret or a public/private key pair and therefore can be verified and trusted.

The JWT standard became so popular mainly due to its simplicity. JWTs are compact, stateless (and therefore are a perfect match for microservice architecture), easy to generate, and process. JWT also works great with all programming languages and can be used across different environments and systems. If this is your first time reading about JWT, I strongly encourage you to learn the basics before proceeding with this post. Here is a great starting point.

Stateless or Stateful?

There are two approaches for users' authentication with tokens, Stateless, or Stateful. Both approaches make use of tokens that are being sent to the client to identify the user in future interactions. However, the main difference is how and where the users' authentication data is stored after a successful login.

Stateful Authentication

The backend generates a random token that represents a session of a user in the system and stores that information in the DB or the memory. When a user accesses one of the protected resources, the session is pulled out of the storage, and the verification process occurs.

Stateless Authentication

The backend generates a token that represents a user and contains the user's data as a payload. When a user accesses one of the protected resources, the token itself is verified, the users' information is extracted from the token itself, and a decision is made.

There are pros and cons to each approach. Most of the advantages of JWT we have mentioned earlier exist thanks to the stateless nature of it. However, those come with a cost that can make our system vulnerable to attacks if a token is stolen from the client.

Access Token vs Refresh Token

To solve most of the security problems that might arise from the use of JWTs, we use refresh tokens. There are many implementations and guides that explain how to create a JWT-based authentication. However, most of them don't use refresh tokens. Refresh tokens are crucial when working with JWTs, so don't skip them!

In general, access tokens are used to authenticate a user, while refresh tokens are used to generate a new access token. Users can't authenticate with a refresh token, and this is an important detail, therefore, our system needs to know how to differentiate the tokens by their types.

How do refresh tokens help us protecting our system?

After a successful login, our backend sends the access token to the client. From that point, the client will pass the token with every request. Allow me to emphasize two things we should NOT DO:

  • We DO NOT store the JWT token in the local storage as it is vulnerable to XSS attacks.
  • We DO NOT store our access token in a cookie (even if it is httpOnly cookie), as it is vulnerable to CSRF attacks. There's another way to handle CSRF attacks, by using SAME SITE but it depends on browser support, so I wouldn't count on it completely just yet.

So what do we do? We use refresh tokens. When a user logs in for the first time, they'll get two tokens:

  • Access token in the payload of the response of the authentication request.
  • A refresh token in an HTTP only cookie.

The access token won't be saved in the local storage of our client application, and here goes the XSS risk. The refresh token will be stored in a cookie and can be used to retrieve a new access token from a dedicated endpoint in future visits to our app. The new access token will be passed to the client in the payload of the request, here goes the CSRF risk. It might sound a little bit confusing at the start, so it's about time to see some code examples to clear things out!

Backend implementation

Generate and encrypt the token

Before we start to work our way towards secured API endpoints, we need to create some Utils to help us achieve our goals. Obviously, we want to be able to generate a JWT. Since a JWT is not encrypted by default, we also want to encrypt it to prevent sensitive information that is stored on the token to leak.

Let's first create our encryption-util, as we'll need to use it in our token-util as well.

import config from '../config/config';
import { createCipheriv, createDecipheriv, scryptSync } from 'crypto';
const secret = config.get('authentication.token.secret');
const algorithm = 'aes-192-cbc';

const key = scryptSync(secret, 'salt', 24);
const iv = Buffer.alloc(16, 0); // Initialization crypto vector

export function encrypt(text: string) {
  const cipher = createCipheriv(algorithm, key, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return encrypted;
}

export function decrypt(text: string) {
  const decipher = createDecipheriv(algorithm, key, iv);
  let decrypted = decipher.update(text, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

Now, we use our encryption-util to generate an encrypted JWT. For the JWT itself, we'll utilize the jsonwebtoken package. Let's create it.

import { decode, sign, verify } from 'jsonwebtoken';
import config from '../config/config';
import { decrypt, encrypt } from './encryption-util';

export enum TokenType {
  ACCESS_TOKEN = 'access_token',
  REFRESH_TOKEN = 'refresh_token',
}

type JWT = { exp: number; type: TokenType; sub: string };

export const generateAccessToken = (userId: string) => {
  return generateToken(userId, TokenType.ACCESS_TOKEN);
};

export const generateRefreshToken = (userId: string) => {
  return generateToken(userId, TokenType.REFRESH_TOKEN);
};

const generateToken = (userId: string, type: TokenType) => {
  const audience = config.get('authentication.token.audience');
  const issuer = config.get('authentication.token.issuer');
  const secret = config.get('authentication.token.secret');
  const expiresIn =
    type === TokenType.ACCESS_TOKEN
      ? config.get('authentication.token.expiresIn')
      : config.get('authentication.refreshToken.expiresIn');

  const token = sign({ type }, secret, {
    expiresIn,
    audience: audience,
    issuer: issuer,
    subject: userId,
  });

  return {
    token: encrypt(token),
    expiration: (decode(token) as JWT).exp * 1000,
  };
};

export const getTokenType = (token: string): TokenType => {
  return (verify(token, config.get('authentication.token.secret')) as JWT).type;
};

export const parseTokenAndGetUserId = (token: string): string => {
  const decryptedToken = decrypt(token);
  const decoded = verify(decryptedToken, config.get('authentication.token.secret')) as JWT;
  return decoded.sub || '';
};

User Authentication with PassportJS

PassportJS is modular authentication middleware for NodeJS, allowing us to use different authentication strategies. Here we create a new passport strategy using the passport-jwt package.

Our frontend uses the authorization HTTP header to provide the access token while making a new request to the backend. When a new request comes in, we need to authenticate the user by the provided token, read the user from the DB, and attach it to the request. The passport strategy contains 2 main parts, the jwtFromRequest and the verifyCallback function. The jwtFromRequest function is responsible for getting the token from the request, and then decrypting, verifying, and checking the type of the token. The verifyCallback function is responsible for getting the user from the database based on the token, and attaching it to the request for future use.

Here's the passport JWT strategy.

import { UserModel } from '../models/user';
import { Strategy as JwtStrategy, VerifiedCallback } from 'passport-jwt';
import config from '../config/config';
import passport from 'passport';
import { Request } from 'express';
import { decrypt } from '../utils/encryption-util';
import { getTokenType, TokenType } from '../utils/token-util';

passport.use(
  new JwtStrategy(
    {
      jwtFromRequest: (req: Request) => {
        try {
          if (!req.headers.authorization) {
            throw new Error('token was not provided, authorization header is empty');
          }

          const tokenFromHeader = req.headers.authorization.replace('Bearer ', '').trim();
          const decryptedToken = decrypt(tokenFromHeader);
          const tokenType = getTokenType(decryptedToken);

          if (tokenType !== TokenType.ACCESS_TOKEN) {
            throw new Error('wrong token type provided');
          }

          return decryptedToken;
        } catch (e) {
          console.error('Token is not valid', e.message);
          return null;
        }
      },
      secretOrKey: config.get('authentication.token.secret'),
      issuer: config.get('authentication.token.issuer'),
      audience: config.get('authentication.token.audience'),
      passReqToCallback: true,
    },
    (req: Request, payload: any, done: VerifiedCallback) => {
      UserModel.findById(payload.sub, (err, user) => {
        if (err) {
          return done(err, false);
        }
        req.currentUser = user?.toObject();
        return !user ? done(null, false) : done(null, user);
      });
    },
  ),
);

Once we have the strategy configured, we need to have a way to restrict our API endpoints. For that, we create a middleware. As discussed before, we work in a stateless way and therefore we set the session value as false.

export const requireAuth = passport.authenticate('jwt', {
  userProperty: 'currentUser',
  session: false,
});

We're almost done with our backend. We need to create the API endpoints, protect them, as send the access_token and the refresh_token to the user. Routes that require authentication will be protected using the requireAuth middleware we created earlier.

import express from 'express';
const router = express.Router();
import { requireAuth } from '../auth';
import config from '../config/config';
import { generateAccessToken, generateRefreshToken } from '../utils/token-util';

const generateTokensAndAuthenticateUser = async (res, userId) => {
  const user = await findUserById(userId);
  const { token: access_token, expiration: token_expiration } = await generateAccessToken(userId);
  const { token: refreshToken } = generateRefreshToken(userId);
  res.cookie('refresh_token', refreshToken, { httpOnly: true });
  res.status(200).json({ access_token, token_expiration, user });
};

router.post('/register', () => {
  try {
    const { email } = req.body;
    const doesEmailExist = await fieldExists('email', email);

    if (!doesEmailExist) {
      const user = await registerUser(req.body);
      generateTokensAndAuthenticateUser(res, user._id);
    }
  } catch (error) {
    handleError(res, error);
  }
});

router.get('/refresh-token', () => {
  try {
    const tokenEncrypted = req.cookies.refresh_token;
    const userId = await parseTokenAndGetUserId(tokenEncrypted);
    generateTokensAndAuthenticateUser(res, userId);
  } catch (error) {
    handleError(res, error);
  }
});

router.get('/logout', requireAuth, () => {
  res.cookie('refresh_token', '', { httpOnly: true });
  res.status(200).end();
});

router.post('/login', async () => {
  try {
    const { email, passport } = req.body;
    const user = await findUser(data.email);
    await checkPassword(password, user);
    generateTokensAndAuthenticateUser(res, user._id);
  } catch (error) {
    handleError(res, error);
  }
});

That's all for now. The full code will be published soon! In the next part, we'll create a React-based web app and use the backend we just created. I hope you find this post helpful, let me know what you think!