React template for JWT authentication with private routes and redirects

JB
Level Up Coding
Published in
14 min readJan 3, 2021

--

This guide is a template to using JWT authentication in React with a MERN app. This code can be used as a template and adjusted as needed for React apps with JWT authentication. The full codebase is here.

This template is for storing user data in an HTTP-only cookie (not localStorage) and accessing the user’s JWT token through that cookie. We can’t read the cookie directly in the browser, so we will have to get the JWT info from the backend, using this recipe with React custom hooks, React frontend components, and a server calls:

  • React custom hook: The frontend (client side) makes a request for the backend (the server) to read the cookie.
  • Server call: The backend reads the cookie with an API call, decodes the JWT if there is one, and sends the results to the frontend.
  • React frontend component: If a user was returned, they are stored in the frontend’s global context. This context lets the app to reference the user and allow them to access protected routes. If a user is not returned, they cannot access protected routes.

This diagram explains how my FE components interact with my custom hooks:

The backend for this app is an Express server and the database is MongoDB. Custom hooks will handle my state (no state management library). Routing is handled by React router. API calls with be handled by Axios. This guide is going to focus on the frontend React piece mainly, and will not go too deeply into the backend. However, I will show the API I made for authentication. If you’re only interested in the frontend, scroll past the Authentication API section to step 1.

All steps:

  1. Create context to store the user, so they can be accessed across the entire application.

2a. Cookie a user and store them in context on log in or registration, so their session will persist.

2b. Create a custom hook to check if a user has a session cookie when they arrive on the site.

3. Store user in global context.

4. Create private routes for authenticated users.

5. Redirect users and conditionally render components by authentication status.

Pre-requisite: Authentication API

I have to different routers: viewRouter, for calls on pageview (checking if a user is logged in), and authRouter for registration, login, and logout. This is in my app.js.

app.use('/', viewRouter);app.use('/auth/', authRouter);

> authRouter.js

const express = require('express');
const authController = require('./../controllers/authController');
const router = express.Router();
router.post('/register', authController.registerUser);
router.post('/login', authController.loginUser);
router.get('/logout', authController.logoutUser);
module.exports = router;

> viewRouter.js

const express = require('express');
const authController = require('../controllers/authController');
const router = express.Router();
router.get('/user', authController.checkUser);module.exports = router;

All of this logic is handled in my authController component, which has different functions for login, registration, logout, setting a token, and signing a token. I will leave this code below, but the rest of the boilerplate, such as the userSchema and error handling, will be in the codebase.

> authController.js

const User = require('./../models/userModel');
const AppError = require('./../utils/AppError');
const catchAsync = require('./../utils/catchAsync');
const jwt = require('jsonwebtoken');
const { promisify } = require('util');
//sign JWT token for authenticated user
const signToken = id => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN
});
}
//create JWT token for authenticated user
const createUserToken = async(user, code, req, res) => {
const token = signToken(user._id);
//set expiry to 1 month
let d = new Date();
d.setDate(d.getDate() + 30);

//first-party cookie settings
res.cookie('jwt', token, {
expires: d,
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
sameSite: 'none'
});
//remove user password from output for security
user.password = undefined;
res.status(code).json({
status: 'success',
token,
data: {
user
}
});
};
//create new user
exports.registerUser = async(req, res, next) => {
//pass in request data here to create user from user schema
try {
const newUser = await User.create({
username: req.body.username,
email: req.body.email,
password: req.body.password,
passwordConfirm: req.body.passwordConfirm
});
createUserToken(newUser, 201, req, res); //if user can't be created, throw an error
} catch(err) {
next(err);
}
};
//log user inexports.loginUser = catchAsync(async(req, res, next) => {
const { username, password } = req.body;

//check if email & password exist
if (!username || !password) {
return next(new AppError('Please provide a username and password!', 400));
}
//check if user & password are correct
const user = await User.findOne({ username }).select('+password');
if (!user || !(await user.correctPassword(password, user.password))) {
return next(new AppError('Incorrect username or password', 401));
}
createUserToken(user, 200, req, res);});//check if user is logged inexports.checkUser = catchAsync(async(req, res, next) => {
let currentUser;
if (req.cookies.jwt) {
const token = req.cookies.jwt;
const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET);
currentUser = await User.findById(decoded.id);
} else {
currentUser = null;
}
res.status(200).send({ currentUser });
});
//log user outexports.logoutUser = catchAsync(async (req, res) => {
res.cookie('jwt', 'loggedout', {
expires: new Date(Date.now() + 10 * 1000),
httpOnly: true
});
res.status(200).send('user is logged out');
});

That’s enough boilerplate—again, the entire server can be found in the codebase. This guide does not cover how JWT works, but there will be more resources at the bottom.

Step 1: Create context to store the user.

Store the user in context allows their data to be accessed across the entire application.

We need to store this user in our app’s global context using React’s createContext and useContext hooks. First, we have to create an context instance using createContext:

src > hooks > UserContext.js

import { createContext } from 'react';export const UserContext = createContext(null);

This is our entire UserContext. Set the initial value of UserContext to null, because there is initially no user. Note: context instances are named with a capital letter (like a component), not a lowercase letter (like a hook). I store context instances in my hooks folder because they function like a hook.

Step 2a: Cookie a user and store them in context on log in or registration.

When a user logs in or registers, four things happen:

  1. A cookie with their JWT token and a one-month life expectancy is set in their browser.
  2. The user’s JWT token is read by the browser.
  3. The decoded user is set in the application’s global context.
  4. The newly authenticated user is pushed to their homepage.

We already have two different components to handle Login and Registration. Each one will use two custom hooks: 1. useForm, which handles the form inputs and state, and 2. useAuth, which handles the authentication. We will focus on useAuth. As an example, I will show registration, but login works the same way.

This is my Registration form

This the code for the page above:

src > pages > Register.js

import React from 'react';
import { Link } from 'react-router-dom';
import FormInput from './../components/FormInput';
import CTA from './../components/CTA';
import Prompt from './../components/Prompt';
import ConfirmPasswordInput from './../components/ConfirmPasswordInput';
import Error from './../components/Error';
import useForm from './../hooks/useForm';
import useAuth from './../hooks/useAuth';
export default function Register() {
const { values, handleChange} = useForm({
initialValues: {
email: '',
username: '',
password: '',
passwordConfirm: ''
}
});
const { registerUser, error } = useAuth();const handleRegister = async (e) => {
e.preventDefault();
await registerUser(values);
}
return(
<div className="page" style={{justifyContent:'center'}}>
<div className="inlineForm">
<h3>Register</h3>
<div className="inlineForm__notif">
{error && <Error error={error.messages}/>}
</div>
<form onSubmit={handleRegister}>
<FormInput type={"text"}
placeholder={"Email"}
name={"email"}
value={values.email}
handleChange={handleChange} />
<FormInput type={"text"}
placeholder={"Username"}
name={"username"}
value={values.username}
handleChange={handleChange} />
<ConfirmPasswordInput type={"password"}
placeholder={"Password"}
name={"password"}
value={values.username}
handleChange={handleChange} />
<div className="inlineForm__submit">
<Link to='/login'>
<Prompt prompt={"Existing account? Log in."}/>
</Link>
<CTA name={"register"} type={"submit"}/>
</div>
</form>
</div>
</div>
)
}

On submit of the form, an async function is called in the page that prevents the default behavior from occurring (the page refreshing on submit) and calls two functions in the useAuth custom hook. So far, that hook has these functions:

src > hooks > useAuth

import { useState, useContext } from 'react';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { UserContext } from './UserContext';
export default function useAuth() {
let history = useHistory();
const { setUser } = useContext(UserContext);
const [error, setError] = useState(null);
//set user in context and push them home
const setUserContext = async () => {
return await axios.get('/user').then(res => {
setUser(res.data.currentUser);
history.push('/home');
}).catch((err) => {
setError(err.response.data);
})
}
//register user
const registerUser = async (data) => {
const { username, email, password, passwordConfirm } = data;
return axios.post(`auth/register`, {
username, email, password, passwordConfirm
}).then(async () => {
await setUserContext();
}).catch((err) => {
setError(err.response.data);
})
};
return { registerUser,
error
}
}

This hook is doing several things with these two functions.

  • registerUser: a POST request is made /auth/register endpoint with the user data in the request body. The user is created in the user database. They are also cookied with a first-party cookie set for a 30-day expiration. (See the API code in the Pre-requisite step for the backend function.)
  • setUserContext: a GET request is made to check if there is a session cookie. If it exists, the user returned from the JWT is stored in the context and the user is pushed /home using the useHistory hook from React Router.
  • Error handling: if these functions can’t be executed the catch block sets an error message. See here for more about error handling.

Note: it is possible to set the user returned from the POST request as the user stored in context. It may seem gratuitous to split this into two calls: a POST to create the user, and a GET to fetch that same user and then store them. However, we will always store users from a GET request when they arrive on the site after authenticating. For a consistent user experience, it’s important to always store the same user object, which is accomplished with the GET request here.

The login process is identical to registration on the Login component, and we add the login function to our useAuth hook.

src > hooks > useAuth

import { useState, useContext } from 'react';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { UserContext } from './UserContext';
export default function useAuth() {
let history = useHistory();
const { setUser } = useContext(UserContext);
const [error, setError] = useState(null);
//set user in context and push them home
const setUserContext = async () => {
return await axios.get('/user').then(res => {
setUser(res.data.currentUser);
history.push('/home');
}).catch((err) => {
setError(err.response.data);
})
}
//register user
const registerUser = async (data) => {
const { username, email, password, passwordConfirm } = data;
return axios.post(`auth/register`, {
username, email, password, passwordConfirm
}).catch((err) => {
setError(err.response.data);
})
};
//login user
const loginUser = async (data) => {
const { username, password } = data;
return axios.post(`auth/login`, {
username, password
}).then(async () => {
await setUserContext();
}).catch((err) => {
setError(err.response.data);
}
return { registerUser,
loginUser,
error
}
}

Step 2b: Create a custom hook to check if a user has a session cookie when they arrive on the site.

Users don’t always log in or register when they’re arriving to a site. Most of the time, the user’s authentication status is read by the cookie created after they authenticated. Without being able to read a user’s cookie, they would be logged out on every hard refresh. So, the application must always check if a user is already authenticated as soon as they arrive on the site. This can be accomplished by running our useFindUser() custom hook as soon as our application is first rendered.

src > hooks > useFindUser.js

import { useState, useEffect } from 'react';
import axios from 'axios';
export default function useFindUser() {
const [user, setUser] = useState(null);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
async function findUser() {
await axios.get('/user')
.then(res => {
setUser(res.data.currentUser);
setLoading(false);
}). catch(err => {
setLoading(false);
});
}
findUser();
}, []);
return {
user,
isLoading
}
}

This call uses two pieces of state: the user, set to null when there is no user, and isLoading, which is true before the call to check the user has completed. This function is the most important for authentication. The useEffect hook will run it as soon as the user arrives on the site. If there is a user, the promise will be resolved and the user will be set in the user state. If there is no user, the promise will be rejected, and the user will correctly remain null. Either way, isLoading will be false.

When this axios call is made, the backend will use the checkUser() function at the /user route to decode and read the JWT token from the cookie in the user’s browser, as it did on login and reg.

Now, let’s call our useFindUser() custom hook and store the returned value in the global context at the top-level of our application. In this case (and most cases) this is the App.js file.

src > App.js

const { user, setUser, isLoading } = useFindUser();

This line is added to App.js before the return statement. The entire App.js code will be below.

Step 3: Store the returned user in the app’s global context.

Whichever way the user is stored in context—either the useAuth hook or the useFindUser hook—their data needs to be accessible over the entire application. We wrap our entire app in the context provider, which makes the data available to any component inside it:

src > App.js

import './App.css';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { UserContext } from './hooks/UserContext';
import Register from './pages/Register';
import Login from './pages/Login';
import Landing from './pages/Landing';
import NotFound from './pages/NotFound';
import useFindUser from './hooks/useFindUser';
function App() {const { user, setUser, isLoading } = useFindUser();return (
<Router>
<UserContext.Provider value={{ user, setUser, isLoading }}>
<Switch>
<Route exact path="/" component={Landing}/>
<Route path="/register" component={Register}/>
<Route path="/login" component={Login}/>
<Route component={NotFound}/>
</Switch>
</UserContext.Provider>
</Router>
);
}
export default App;

The data set as the value property is now accessible in any component above.

If a console.log(user), I will see this object in the console:

This is now accessible like any typical object, and I can return user.username or user.email to get individual properties.

Note: never store raw user passwords in context. Hash the password (as I did above) or remove the password field!

Step 4: Create a private route component for authenticated users.

In applications with authentication, there are “protected routes” that only those logged in users can access. To prevent non-authenticated users from accessing certain routes, we can create a PrivateRoute component that “screens” users for authentication status and responds accordingly.

If a user is authenticated, they can proceed to the route, which is inside the PrivateRoute component. If a user is not authenticated, we handle them by directing them a generic, public route.

src > pages > PrivateRoute.js

import React, { useContext } from 'react';
import { Route, Redirect } from 'react-router-dom';
import { UserContext } from './../hooks/UserContext';
import Loading from './../components/Loading';
export default function PrivateRoute(props) { const { user, isLoading } = useContext(UserContext);
const { component: Component, ...rest } = props;
if(isLoading) {
return <Loading/>
}
if(user){
return ( <Route {...rest} render={(props) =>
(<Component {...props}/>)
}
/>
)}
//redirect if there is no user
return <Redirect to='/login' />
}

This is the PrivateRoute component, where the user is directed when they try to access a protected route. The React component the authenticated user will see is passed to this component as a prop. So if the Home component is protected, it will be passed as the <Component/> here.

Note: It’s important to render the component inside a <Route/> , and not just return <Component/> . If just the component is returned, the user will be directed correctly, but they won’t be able to access the props from React Router, like useHistory, useParams, or state from the Link component.

The PrivateRoute has three possible outcomes: 1. loading, in which case a loading screen is shown (or you can return null if you don’t want to show a loading screen), 2. not loading and a user is found, in which case they are routed to the component, and 3. not loading and a user is not found, in which case they are directed to the login page.

Adding a PrivateRoute component to our App.js is simple:

src > App.js

import './App.css';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { UserContext } from './hooks/UserContext';
import PrivateRoute from './pages/PrivateRoute';
import Register from './pages/Register';
import Login from './pages/Login';
import Landing from './pages/Landing';
import Home from './pages/Home';
import NotFound from './pages/NotFound';
import useFindUser from './hooks/useFindUser';
function App() {const { user, setUser, isLoading } = useFindUser();return (
<Router>
<UserContext.Provider value={{ user, setUser, isLoading }}>
<Switch>
<Route exact path="/" component={Landing}/>
<Route path="/register" component={Register}/>
<Route path="/login" component={Login}/>
<PrivateRoute path="/home" component={Home}/>
<Route component={NotFound}/>
</Switch>
</UserContext.Provider>
</Router>
);
}
export default App;

Now, when I register with a new user, they are immediately taken here:

This is only accessible for logged in users

This component is the Home returned from Private Route.

Step 5: Redirect users and conditionally render components by authentication status.

Some components are accessible to all users, but should change based on authentication status. This can be handled with redirects and conditional rendering.

Redirect authenticated users

If we have a public landing page for unauthenticated users, we may not want authenticated users to see it. Authenticated users should be redirected to their personalized, private homepage.

import React, { useContext } from 'react';
import Header from '../sections/Header';
import { Redirect } from 'react-router-dom';
import { UserContext } from '../hooks/UserContext';
export default function Landing() {
const { user } = useContext(UserContext);

if(user) {
<Redirect to='/home'/>
}
return (
<div className="page">
<Header/>
<h3>This is the public landing page</h3>
</div>
)
}

If there is a user, they will immediately be redirected to the private /home route before the return statement shows them the generic, public landing page.

Conditional rendering

Different versions of the same component may be needed for public vs. private routes. The typical usecase for conditional rendering is a call to action button: unknown users should see a Login button, while known users should see a Logout button. However, there are many cases where the same component will change for known vs. unknown users.

An example of this in my application is the Header component. If a user is stored, the header reflects that I’m logged in: the Logout button is presented instead of Login, and my username (retrieved from context) is dynamically populated.

src > sections > Header.js

import React, { useContext } from 'react';
import InlineButton from './../components/InlineButton';
import { UserContext } from '../hooks/UserContext';
import useLogout from './../hooks/useLogout';
export default function Header() {
const { user } = useContext(UserContext);
const { logoutUser } = useLogout();
return(
<header>
{user
?

<>
Hello, {user.username}
<InlineButton name={'logout'} handleClick={logoutUser} />
</>
:
<div className='btnGroup'>
<Link to = "/login">
<InlineButton name={"login"}/>
</Link>
<Link to = "/register">
<InlineButton name={"register"}/>
</Link>
</div>
}
</header>
)
}

This is my basic Header component that uses a ternary operator to conditionally render two different versions of the header if there is or isn’t a user. As you can see, when there is a user, I use user.username to personalize the page with my username.

Conclusion

That’s about it for a basic template! There is much more to authentication, but I hope this template provides a sizable foundation. If someone has a more elegant pattern, I would appreciate any relevant resources. I found it difficult to find many comprehensive authentication guides. In sum, the pieces covered here are:

  • Storing authenticated users in global context.
  • Checking if a user is logged in based on their HTTP-only cookie/JWT token.
  • Protecting routes for authenticated users only.
  • Redirecting users and conditional rendering based on their authentication status.

Project repo

Some resources I used & found helpful:

--

--