DEV Community

Cover image for Handling authentication in NodeJS using JWT Tokens
Jaydeep Dey
Jaydeep Dey

Posted on

Handling authentication in NodeJS using JWT Tokens

Introduction

The process of authenticating a user's identity involves obtaining credentials and utilizing those credentials to verify the user's identity. We all authenticate into several services in our day-to-day life, for example logging into pc, logging in to email etc. It helps us gain personalized access to an application. Authentication can help user to maintain privacy with their data.

Difference between Authentication and Authorization

It might seem confusing at the first sight as to how these terms convey different meanings. People generally use it interchangeably, which is a misconception. While authentication is the process of validating who the user is. On the other hand, Authorization is the process of granting access to a particular resource which is protected, depending on what permission the user has to access them.

Role Based Access Control (RBAC) 👩‍💻

Role-based access control is a way of defining what sort of permissions a user can get based on the role assigned to them. Roles will be assigned to do some particular action or access certain resources. Here the term "Authorization" fits perfectly as users are authorized to use certain features or access certain resources.

Understanding Client-Server Model for Authentication đź’»

In this scenario, we define two types of JWT Token which get passed between client and server to gain authorization, namely accessToken and refreshToken

  • accessToken : The server issues access tokens only when it wants the client to access protected routes. Access Tokens are short-lived.

    Note:

    • Access Tokens are issued by the server to authorize a client, hence these tokens should not be stored in persistent browser storage such as localStorage or cookies . The recommended way of storing them is to store them in-memory storage so that it's lost when the application shuts down.
    • The reason for accessToken being in-memory is to prevent hackers from accessing these tokens. As in-memory storage is not javascript accessible, which means even if a hacker tried to inject malicious Javascript code, the accessToken can't be retrieved.
  • refreshToken : The refresh token is issued when the user authenticates into the application. Refresh Tokens are long-lived.

    Note:

    • refreshToken are stored in the browser in the form of httpOnly Cookies, which javascript cannot access and are valid only till the user is authenticated, in other words only when the user is logged in
    • refreshToken can generate new accessToken when /refresh endpoint of REST API is called.
    • A reference of refreshToken is stored in the database, so that if the user decides to logout early the refreshToken stored in the Database can be deleted and the session can be terminated
    • The reference of refreshToken, stored in the database, is cross-verified with the refreshToken stored in httpOnly cookie on client-request to REST API to verify the session.
    • refreshToken should not be allowed to generate new refreshToken to prevent indefinite access to an unauthorized person, if they somehow manage to get refreshToken

diagram

Implementation on Backend

The code implementation will be done using React on the client side and ExpressJS on the backend side. MongoDB is used as Database.

The required package.json for the backend is mentioned below:

// package.json for backend
"dependencies": {
    "bcrypt": "^5.1.0",
    "body-parser": "^1.20.1",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^6.5.4",
    "nodemon": "^2.0.19"
  }
Enter fullscreen mode Exit fullscreen mode

Issuing refreshToken

  • When the user logs into the application

    const login = (req, res, next) => {
        const { email, password } = req.body;
        let errors = [];
        if (!email || !password) {
            errors.push({ msg: 'Please fill in all fields' });
            res.status(400).json({ errors });
        }
        User.findOne({ email })
            .then(user => {
                if (!user) {
                    errors.push({ msg: 'Email is not registered' });
                    res.status(400).json({ errors });
                } else {
                    bcrypt.compare(password, user.password, async (err, isMatch) => {
                        if (err) throw err;
                        if (isMatch) {
    // accessToken and refreshToken is being issued
                            const accessToken = jwt.sign(
                                {
                                    userInfo: {
                                        name: user.name,
                                        role: user.role
                                    },
                                }
                                , process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
                            const refreshToken = jwt.sign({ name: user.name, role: user.role }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '1d' });
    
                            // adding a refreshToken in mongodb
                            await User.findOneAndUpdate({ _id: user._id }, { refreshToken: refreshToken })
                            res.cookie('refreshToken', refreshToken, { maxAge: 1000 * 60 * 60 * 24, sameSite: 'none', httpOnly: true, secure: true });
                            res.status(200).json({ role: user.role, accessToken: accessToken });
                        } else {
                            errors.push({ msg: 'Password is incorrect' });
                            res.status(400).json({ errors });
                        }
                    })
                }
            })
    }
    
  • verifying refreshToken when accessToken expires

    const User = require('../Models/user')
    const jwt = require('jsonwebtoken')
    
    const handleRefreshToken = async (req, res) => {
        const cookies = req.cookies;
        // ensure cookie is set
        if (!cookies?.refreshToken) res.status(401).json({ msg: 'No cookies' });
        const refreshToken = cookies.refreshToken;
    
        // cross verify refreshToken with the stored refreshToken in DB
        const person = await User.findOne({ refreshToken })
        if (!person) res.status(401).json({ msg: 'No person' });
    
        // evalutate jwt
        jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
            const role = Object.values(user.role)
            if (err) res.status(403).json({ msg: 'Invalid token' });
            // accessToken is issued on successful verification
            const accessToken = jwt.sign({
                userInfo: {
                    name: user.name,
                    role: user.role
                },
            }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
            res.status(200).json({ accessToken: accessToken, role: role });
        })
    
    }
    

Here, we are encrypting the user role with accessToken and refreshToken so that RBAC is implemented on the server side

When the user successfully logs-in into the website

The refreshToken is issued to the client in the response header as a httpOnly cookie. The cookie is set in the browser which will be used subsequently to retrieve new accessToken when it expires.

The accessToken is stored in Browser's in-memory and hence, is not visible. But on logging into the console. We verify that it has been issued. In the production environment, never log it to the console.

The accessToken is passed as a Bearer Token in request Authorization header which then helps the client to access protected routes.

We create a middleware function to verify accessToken

const verify = (req, res, next) => {
    const authHeader = req.headers.authorization || req.headers.Authorization;
    if(!authHeader?.startsWith('Bearer ')) return res.status(401).json({ msg: 'Unauthorized' });
    const token = authHeader.split(' ')[1];
    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded)=> {
        if(err) return res.status(403).json({ msg: 'Invalid token' });
        console.log(decoded)
        req.role = decoded.userInfo.role
        next()
    })
}
Enter fullscreen mode Exit fullscreen mode

The verification of role is done by another middleware function

const verifyRoles = (...allowedRoles) => {
    return (req, res, next) => {
        if (!req?.role) return res.status(401).json({ msg: 'You are not authenticated' });
        const rolesArray = [...allowedRoles]
        const result = req.role.map(role => rolesArray.includes(role)).find(val => val === true);
        if (!result) return res.status(401).json({ msg: 'You are not authorized to access this route' });
        next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Implementation of a protected route on Frontend đź”’

react-router-dom v6 allowed us to protect routes based on roles assigned to the user. The roles will be fetched from the backend based on accessToken.

The implementation is as follows:

App.jsx

import { Routes, Route } from "react-router-dom"
import Home from "./Pages/Home";
import Loginscreen from "./Pages/Loginscreen";
import Unauthorised from "./Components/Unauthorised";
import UserProvider from "./context/Usercontext";
import Admin from "./Pages/Admin";
import Layout from "./Layout";
import Registerscreen from "./Pages/Registerscreen";
import Missing from "./Missing";
import RequireAuth from "./RequireAuth";
import EditorScreen from "./Pages/EditorScreen";
import Persistlogin from "./Components/Persistlogin";

function App() {

  return (
    <UserProvider>
      <Routes>
          {/* Public Routes (without logged in) */}
          <Route path="/login" element={<Loginscreen />} />
          <Route path="/unauthorised" element={<Unauthorised />} />
          <Route path="/register" element={<Registerscreen />} />


          <Route element={<Persistlogin />}>
            {/* Any User can access with user role */}
            <Route element={<RequireAuth allowedRoles={[2000]} />}>
              <Route path="/" element={<Home />} />
            </Route>


            {/* Protected Routes (Admin) */}
            <Route element={<RequireAuth allowedRoles={[1000]} />}>
              <Route path="/admin" element={<Admin />} />
            </Route>

            {/* Protected Routes(Editor) */}
            <Route element={<RequireAuth allowedRoles={[3000]} />}>
              <Route path="/editor" element={<EditorScreen />} />
            </Route>
          </Route>

          {/* Catch all */}
          <Route path="*" element={<Missing />} />
      </Routes>
    </UserProvider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The requireAuth.jsx component is created to manage protected route access

import { useLocation, Navigate, Outlet } from "react-router-dom"
import useAuth from "./hooks/useAuth"

const RequireAuth = ({ allowedRoles }) => {
    // useAuth is a custom hook created to access the global user context store
    const { auth } = useAuth()
    const location = useLocation()

    return (
        <div>
            {auth?.role?.find(role1 => allowedRoles.includes(role1))
                ? <Outlet />
                : auth?.email ?
                    <Navigate to="/unauthorised" state={{ from: location }} replace />
                    : <Navigate to="/login" state={{ from: location }} replace />
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

The <Outlet/> the component is an inbuilt feature of react-router-dom v6 which helps to render children component which are wrapped around the <Route></Route> component.

One major problem comes when a user tries to refresh the page, the auth state is reset when the window is refreshed. To maintain persist login state we define another component PersistLogin.jsx

import useRefreshToken from "../hooks/useRefreshToken"
import useAuth from "../hooks/useAuth"
import { useState, useEffect } from "react"
import { Outlet } from "react-router-dom"

const Persistlogin = () => {
    const refresh = useRefreshToken()
    const { auth } = useAuth()
    const [loading, setLoading] = useState(true)

    useEffect(() => {
        const verifyRefreshToken = async () => {
            try {
                await refresh()
            } catch (error) {
                console.error(error)
            }
            finally {
                setLoading(false)
            }

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

    useEffect(() => {
        console.log(`loading: ${loading}`)
        console.log(`accessToken: ${JSON.stringify(auth?.accessToken)}`)
    }, [loading])

    return (
        <>
            {loading ? <p>Loading...</p> : <Outlet />}
        </>
    )
}

export default Persistlogin
Enter fullscreen mode Exit fullscreen mode

Accessing protected routes

When an admin logs into the application and tries to access the admin page, he is given the access.

But when the admin navigates to the editor's page, he gets the following error.

As soon as the accessToken expires, the /refresh the endpoint is hit to fetch new accessToken

The network activity is demonstrated:

the /getUser route is protected and when it's being accessed through expired accessToken, we get a 403 Forbidden response. The refreshToken is called subsequently to fetch a new accessToken. This is achieved with the help of Axios Interceptors.

Axios Interceptors

Let's demonstrate the as to how new accessTokens are fetched from the server when it gets expired using Axios interceptor

Axios is a powerful promise-based HTTP Library. One of its coolest features is the Axios interceptors.

Axios interceptors are similar to the middleware function of ExpressJS.

Request interceptors help us define if we want to do any operation before sending a request to the server.

axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    return config;
  }, function (error) {
    // Do something with request error
    return Promise.reject(error);
  });
Enter fullscreen mode Exit fullscreen mode

In our application, we have defined the Request interceptor to pass accessToken as Bearer Token in the Authorization Header.

const requestIntercept = axiosPrivate.interceptors.request.use(
            config => {
                if (!config.headers['Authorization']) {
                    config.headers['Authorization'] = `Bearer ${auth?.accessToken}`
                }
                return config
            },
            error => Promise.reject(error)
        )
Enter fullscreen mode Exit fullscreen mode

Similarly,

Response interceptors are defined as when we want to do any operation after we receive the response from the server.

axios.interceptors.response.use(function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  }, function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  });
Enter fullscreen mode Exit fullscreen mode

In this application, we have implemented a Response interceptor to resend accessToken as a request header once we detected 403 Forbidden Status, which comes for expired accessToken.

const reponseIntercept = axiosPrivate.interceptors.response.use(
// if successful return response
            (response) => response,
            async (error) => {
// if there is an error, which comes from expired accessToken
                const prevRequest = error?.config;
                if (error?.response?.status === 403 && !prevRequest?.sent) {
// issue new accessToken from refresh endpoint and send it to the request header
                    const newAccessToken = await refresh()
                    return axiosPrivate({
                        ...prevRequest,
                        headers: { ...prevRequest.headers, Authorization: `Bearer ${newAccessToken}` },
                        sent: true
                    });
                }
                return Promise.reject(error);
            }
        )
Enter fullscreen mode Exit fullscreen mode

Logging out a User

When the user tries to logout, the browser stored httpOnly cookie storing refreshToken is deleted. The DB stored refreshToken is also cleared simultaneously.

const logout = (req, res) => {
    const cookies = req.cookies;
    if (!cookies?.refreshToken) res.status(204).json({ msg: 'No cookies' });
    const refreshToken = cookies.refreshToken;
    User.findOneAndUpdate({ refreshToken }, { refreshToken: '' }, (err, doc) => {
        if (err) {
            res.status(500).json({ msg: 'Something went wrong' })
        }
        // maxAge need not be set during clearCookie
        res.clearCookie('refreshToken', { httpOnly: true, sameSite: 'none', secure: true });
        res.status(200).json({ msg: 'Logged out' });
    })
}
Enter fullscreen mode Exit fullscreen mode

Things to keep in mind

Cross-Origin Resource Access

We have our react app running on http://localhost:3000/

and our backend express app running on http://localhost:5000/

It's evident we are trying to access resources from the backend, from a different origin, i.e. our react app.

This will give us a CORS Error, which keeps the site, safe by blocking all sorts of Malicious cross-site scripting activity.

In the Development environment we might encounter this error, so to prevent it, we use CORS Middleware, specifying the origin which gets permission to pass through this policy.

CORS Middleware can be installed by this command

npm i cors or yarn add cors

const allowedOrigin = [
    'http://localhost:3000',
    'http://localhost:5000',
    'http://localhost:3001',
    'http://localhost:5001',
]

const corsOption = {
    origin: (origin, cb)=> {
        if(allowedOrigin.indexOf(origin) !== -1 || !origin){
            cb(null, true)
        }
        else{
            cb(new Error("Not allowed by CORS"))
        }
    },
    optionsSuccessStatus: 200,
}
Enter fullscreen mode Exit fullscreen mode

We pass the corsOption in the CORS Middleware as follows

app.use(corsOption)
Enter fullscreen mode Exit fullscreen mode

Configuring cookies to allow CORS

In backend:

When sending and receiving cookies, make sure to set secure: true, and sameSite: 'none' to allow cookies to be shared cross-origin

res.cookie('refreshToken', refreshToken, { maxAge: 1000 * 60 * 60 * 24, sameSite: 'none', httpOnly: true, secure: true });
Enter fullscreen mode Exit fullscreen mode

Similarly,

res.clearCookie('refreshToken', { httpOnly: true, sameSite: 'none', secure: true });
Enter fullscreen mode Exit fullscreen mode

NOTE: To test out backend functionalities through Postman or any other similar application, make sure to set secure: false, as it doesn't allow cookies to be set in the application, which eventually leads to errors.

In Frontend,

While making Axios request, make sure you set withCredentials: true to allow browsers to set cookies before making a request.

await axios.get('/refresh', { withCredentials: true })
Enter fullscreen mode Exit fullscreen mode

Resources Link

Some of the links to useful resources are given, you can refer them for more indepth knowledge on this top. 🚀

Thanks a lot for reading my article. ✨

Top comments (2)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Hi, thanks for the article. One detail I see: Whenever a token expires or is in any way or form invalid, the HTTP status code that should be returned is 401, not 403. Status code 403 (Forbidden) means "I acknowledge that you are authenticated (meaning the token is valid). However, your current authentication lacks enough privileges to perform the requested action."

So basically you only return status code 403 to properly authenticated users that do not have the appropriate level of access for an action. Users that do not authenticate properly should receivea status code of 401.

Cheers.

Collapse
 
jaydeepdey03 profile image
Jaydeep Dey

Thanks Jose, surely I will note it and make sure I incorporate this change in my code. 🙏