DEV Community

Cover image for Set up JWT authentication in MERN from scratch
Jeffrey Yu
Jeffrey Yu

Posted on • Updated on

Set up JWT authentication in MERN from scratch

Nearly every web project needs user authentication. In this article I will share how I implement auth flow in my MERN stack projects. This implementation can be applied in every project that registers users with email and password.

How it works

First of all, JSON Web Token is a popular library that provides functions to create a unique, encrypted token for a user's current login status, and verify if a token is invalid and not expired.

The app's authentication flow is demonstrated below:

auth-flow

When a user clicks register or login, the correponding Express route returns a jwt token. The token gets stored in the browser localStorage so that a user can come back three days later without login again.

Every protected route in Express (that needs user's login status) has an auth middleware. React puts the localStorage token in the x-auth-token header when calling these protected routes.

In the middleware, jwt verifies if the token in the header is valid and hasn't expired. If so, it processes to the route; if not, Express returns 403 and React prompts the user back to the login page.

Express register route

The register route receives email and password in the request body. If the user with the email doesn't exist, it creates a new user with the password hashed by bcrypt, and stores it into the Mongoose User model. Finally it returns a signed jwt token.

const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../models/User');

router.post('/user', async (req, res) => {
    const { email, password } = req.body;

    try {
      // check if the user already exists
      user = await User.findOne({ email });
      if (user) {
        return res.status(400).json({ msg: 'Email already exists' });
      }

      // create new user
      user = new User({
        email,
        password,
      });

      // hash user password
      const salt = await bcrypt.genSalt(10);
      user.password = await bcrypt.hash(password, salt);
      await user.save();

      // return jwt
      const payload = {
        user: {
          id: user.id,
        },
      };

      jwt.sign(
        payload,
        process.env.JWT_SECRET,
        { expiresIn: '7 days' },
        (err, token) => {
          if (err) throw err;
          res.json({ token });
        }
      );
    } catch (err) {
      console.error(err.message);
      res.status(500).send('Server error');
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Express login route

The login route also receives email and password. If the user with the email exists, it compares the hash password and returns a signed token if succeeds.

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

    try {
      // check if the user exists
      let user = await User.findOne({ email });
      if (!user) {
        return res.status(400).json({ msg: 'Email or password incorrect' });
      }

      // check is the encrypted password matches
      const isMatch = await bcrypt.compare(password, user.password);
      if (!isMatch) {
        return res.status(400).json({ msg: 'Email or password incorrect' });
      }

      // return jwt
      const payload = {
        user: {
          id: user.id,
        },
      };

      jwt.sign(
        payload,
        process.env.JWT_SECRET,
        { expiresIn: '30 days' },
        (err, token) => {
          if (err) throw err;
          res.json({ token });
        }
      );
    } catch (err) {
      console.error(err.message);
      res.status(500).send('Server error');
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Express get user info route

Since login and register only returns a token, this route returns the user info given the token.

router.get('/user/info', auth, async (req, res) => {
  try {
    const user = await UserModel.findById(req.user.id).select('-password');
    res.status(200).json({ user });
  } catch (error) {
    res.status(500).json(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

Express auth middleware

The auth middleware verifies the token exists and is valid before preceeds to a protected route.

const jwt = require('jsonwebtoken');

module.exports = function (req, res, next) {
  // Get token from header
  const token = req.header('x-auth-token');

  // Check if no token
  if (!token) {
    return res.status(401).json({ msg: 'No token, authorization denied' });
  }

  // Verify token
  try {
    jwt.verify(token, process.env.JWT_SECRET, (error, decoded) => {
      if (error) {
        return res.status(401).json({ msg: 'Token is not valid' });
      } else {
        req.user = decoded.user;
        next();
      }
    });
  } catch (err) {
    console.error('something wrong with auth middleware');
    res.status(500).json({ msg: 'Server Error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Then in every protected route, add the auth middleware like this:

const auth = require('../middleware/auth');
router.post('/post', auth, async (req, res) => { ... }
Enter fullscreen mode Exit fullscreen mode

React auth context

I use useReducer to store auth status and user info, and use useContext to provide the reducer state and actions including login, register, and logout.

The login and register actions store the token returned from axios requests in localStorage and calls the user info route with the token.

On reducer state init or change, the user info route will be called to make sure the user info is in the reducer and the axios auth header is set if the user is logined.

import { createContext, useEffect, useReducer } from 'react';
import axios from 'axios';

const initialState = {
  isAuthenticated: false,
  user: null,
};

const authReducer = (state, { type, payload }) => {
  switch (type) {
    case 'LOGIN':
      return {
        ...state,
        isAuthenticated: true,
        user: payload.user,
      };
    case 'LOGOUT':
      return {
        ...state,
        isAuthenticated: false,
        user: null,
      };
  }
};

const AuthContext = createContext({
  ...initialState,
  logIn: () => Promise.resolve(),
  register: () => Promise.resolve(),
  logOut: () => Promise.resolve(),
});

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, initialState);

  const getUserInfo = async () => {
    const token = localStorage.getItem('token');

    if (token) {
      try {
        const res = await axios.get(`/api/user/info`);
        axios.defaults.headers.common['x-auth-token'] = token;

        dispatch({
          type: 'LOGIN',
          payload: {
            user: res.data.user,
          },
        });
      } catch (err) {
        console.error(err);
      }
    } else {
      delete axios.defaults.headers.common['x-auth-token'];
    }
  };

  // verify user on reducer state init or changes
  useEffect(async () => {
    if (!state.user) {
        await getUserInfo();
    }
  }, [state]);

  const logIn = async (email, password) => {
    const config = {
      headers: { 'Content-Type': 'application/json' },
    };
    const body = JSON.stringify({ email, password });

    try {
      const res = await axios.post(`/api/user/login`, body, config);
      localStorage.setItem('token', res.data.token);
      await getUserInfo();
    } catch (err) {
      console.error(err);
    }
  };

  const register = async (email, password) => {
    const config = {
      headers: { 'Content-Type': 'application/json' },
    };
    const body = JSON.stringify({ email, password });

    try {
      const res = await axios.post(`/api/user/register`, body, config);
      localStorage.setItem('token', res.data.token);
      await getUserInfo();
    } catch (err) {
      console.error(err);
    }
  };

  const logOut = async (name, email, password) => {
    try {
      localStorage.removeItem('token');
      dispatch({
        type: 'LOGOUT',
      });
    } catch (err) {
      console.error(err);
    }
  };

  return (
    <AuthContext.Provider value={{ ...state, logIn, register, logOut }}>
      {children}
    </AuthContext.Provider>
  );
};

export default AuthContext;
Enter fullscreen mode Exit fullscreen mode

I put useContext in customized hook - just a good practice to access to context easily.

import { useContext } from 'react';
import AuthContext from '../contexts/FirebaseAuthContext';

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

export default useAuth;
Enter fullscreen mode Exit fullscreen mode

React guest & user guard

Guard components are simple auth navigation components that wrap around other components. I use guard components so that the auth navigation logic is seperated from individual components.

Guest guard navigates unlogined user to login and is wrapped around protected pages.

import { Navigate } from 'react-router-dom';
import useAuth from '../hooks/useAuth';

const GuestGuard = ({ children }) => {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" />;
  }
  return <>{children}</>;
};
Enter fullscreen mode Exit fullscreen mode
<GuestGuard>
  <PostReview />
</GuestGuard>
Enter fullscreen mode Exit fullscreen mode

User guard navigates logined user to home page and is wrapped around login and register pages.

const UserGuard = ({ children }) => {
  const { isAuthenticated } = useAuth();

  if (isAuthenticated) {
    return <Navigate to="/dashboard" />;
  }
  return <>{children}</>;
};
Enter fullscreen mode Exit fullscreen mode
<UserGuard>
  <Login />
</UserGuard>
Enter fullscreen mode Exit fullscreen mode

This is how to setup JWT auth in MERN from scratch. The user and email registration would work well for small-scale projects, and I would recommend implementing OAuth as the website scales.

Discussion (2)

Collapse
t0nyba11 profile image
Tony B

I am not a React expert, but ignoring the client-side for a moment, what stops someone calling your /user/info back-end API directly from the console iterating ID's, and getting other users' info?

Collapse
jeffreythecoder profile image
Jeffrey Yu Author • Edited on

/user/info should pass through the auth middleware and I just added it. The auth middleware decodes the user payload in jwt to req.user, and the fetched user info should have the same user id as req.user.id.

Essentially a user can only get the user info with a given token, not an id.