Authentication and Authorization are crucial for any software to be built today. In this article, we’ll explore with the help of a demo, how to set up authentication in a Create React App. We will implement a basic straightforward JWT-based bearer token authentication.
The components of our setup will be like:
- A headless CMS - Hygraph.
- A Backend Server - Node.js.
- A Frontend App - Create React App.
It is completely fine to choose your own backend server and database as well. The code for the final frontend and backend apps can be found here.
#Setting Up The Backend
Hygraph Schema
To set up the base, we should begin by creating our User schema in Hygraph dashboard. We will keep fields like firstname
, lastname
, email
, and password
in the schema, it will look something like this:
Once this schema is configured, we can move on to build the backend Node.js Express API.
Node.js Backend Application
Base Setup
Do an npm init
in a fresh folder to create a new application and install the following dependencies using npm install
We will be using bcryptjs
to hash passwords, express
and cors
to manage the backend API, dotenv
to support environment variables via a .env
file, jsonwebtoken
to sign, and decode the jwt-tokens for authentication, and finally graphql
, graphql-request
to fetch data from Hygraph GraphQL API.
Let's begin with some base backend server setup. Please add the following files
.env
JWT_SECRET=SUPERSECRETJWT_EXPIRES_IN=1 hourHYGRAPH_URL=VALUEHYGRAPH_PERMANENTAUTH_TOKEN=VALUE
index.js
import 'dotenv/config';import app from './app.js';const port = process.env.PORT || 4000;app.listen(port, () => {console.log(`Backend API Ready On Port: ${port}`);});
app.js
import express from 'express';import authRoutes from './routes/authRoutes.js';import cors from 'cors'const app = express();app.use(express.json());app.use(cors());app.use(authRoutes);export default app;
graphql/client.js
import { GraphQLClient } from 'graphql-request';const { HYGRAPH_URL, HYGRAPH_PERMANENTAUTH_TOKEN } = process.env;const client = new GraphQLClient(HYGRAPH_URL, {headers: {Authorization: `Bearer ${HYGRAPH_PERMANENTAUTH_TOKEN}`,},});export default client;
graphql/mutations.js
import { gql } from 'graphql-request';export const CreateNextUserMutation = gql`mutation CreateNextUser($userData: NextUserCreateInput!) {createNextUser(data: $userData) {id}}`;export const GetUserByEmailQuery = gql`query getUserByEmailQuery($email: String!) {nextUser(where: { email: $email }, stage: DRAFT) {idfirstnamelastnamepassword}}`;
In a backend application we generally have Routes, Controllers and Services. Let us use the same framework to create three APIs that we will need, one to Sign Up, one to Sign In, and one to verify or get the user from an issued access token.
Routes
Let us first add the routes
routes/authRoutes.js
import express from 'express';import AuthController from '../controllers/authController.js';const router = express.Router();const authController = new AuthController();router.post('/auth/signup', (req, res) => authController.signup(req, res));router.post('/auth/signin', (req, res) => authController.signin(req, res));router.get('/auth/me', (req, res) => authController.getCurrentUser(req, res));export default router;
We have defined three routes with the endpoints - /auth/signup
, /auth/signin
, and /auth/me
respectively.
Controller
Now let us add the controller for these three routes
controllers/authController.js
import AuthService from "../services/authService.js";class AuthController {constructor() {this.authService = new AuthService();}async signup(req, res) {try {const { email, password, firstname, lastname } = req.body;if (!email || !password || !firstname || !lastname) {res.status(400).end();return;}const { user, token } = await this.authService.signup({email,password,firstname,lastname,});res.send({ user, token });} catch (err) {console.error("POST auth/signup, Something Went Wrong:", err);res.status(400).send({ error: true, message: err.message });}}async signin(req, res) {try {const { email, password } = req.body;if (!email || !password) {res.status(400).end();return;}const token = await this.authService.signin(email, password);res.status(200).json({ token });} catch (err) {console.error("POST auth/signin, Something Went Wrong:", err);res.status(400).send({ error: true, message: err.message });}}async getCurrentUser(req, res) {const defaultReturnObject = { authenticated: false, user: null };try {const token = String(req.headers.authorization?.replace("Bearer ", ""));const user = await this.authService.getCurrentUser(token);res.status(200).json({ authenticated: true, user });} catch (err) {console.error("GET auth/me, Something Went Wrong:", err);res.status(400).json(defaultReturnObject);}}}export default AuthController;
In all three functions of the class - signup, signin, getCurrentUser
, we have done these steps:
- Destructure input from the request.
- Validate the input.
- Call the service function to run the respective logic.
- Get response from service and send it back to the client.
- Catch and throw any run time errors.
Service
Finally, let us create the service file step by step and the first step would be to create the skeleton for our service file where the actual authentication logic will be placed for each API.
services/authService.js
import bcrypt from "bcryptjs";import jwt from "jsonwebtoken";import gqlClient from "../graphql/gqlClient.js";import {CreateNextUserMutation,GetUserByEmailQuery,} from "../graphql/mutations.js";const { JWT_SECRET, JWT_EXPIRES_IN } = process.env;class AuthService {async signup(signupRequest) {// SIGNUP LOGIC}async signin(email, password) {// SIGN IN LOGIC}async getCurrentUser(token) {// GET CURRENT USER LOGIC}}export default AuthService;
Now let us add and understand individual functions step by step, starting with the sign up function:
// SIGN UP LOGICasync signup(signupRequest) {const { email, password, firstname, lastname } = signupRequest;const hashedPassword = await bcrypt.hash(password, 8);const userData = {email,password: hashedPassword,firstname,lastname,};const response = await gqlClient.request(CreateNextUserMutation, {userData,});if (!response?.createNextUser) {throw new Error("CreateUser Failed");}const token = jwt.sign({ user: response.createNextUser }, JWT_SECRET, {expiresIn: JWT_EXPIRES_IN,});return { user: response.createNextUser, token };}
For Sign Up, we get the new user details from the controller, then we hash the password and save the user in the database, finally we create a jwt-token with the user details and send the token back to the client.
Moving on to the sign in functionality, here we will accept email and password, get the user details from our database, and compare the given and stored password hash with bcrypt. We throw an error if the passwords do not match, else we create a fresh jwt token and send it back to the client.
// SIGN IN LOGICasync signin(email, password) {const getUserResponse = await gqlClient.request(GetUserByEmailQuery, {email,});const { nextUser } = getUserResponse;if (!nextUser) {throw new Error("Invalid Email Or Password");}const isMatch = await bcrypt.compare(password, nextUser.password);if (!isMatch) {throw new Error("Invalid Email Or Password");}const token = jwt.sign({id: nextUser.id,email: nextUser.email,},JWT_SECRET,{ expiresIn: JWT_EXPIRES_IN });return token;}
Finally, one API to get the currently authenticated user details from an existing access token. It will accept and verify the access token, and send the user object back if the token is valid. This idea of this getCurrentUser function can be expanded further as a middleware to verify your user before running the logic for all your other protected APIs in the backend.
// GET CURRENT USER LOGICasync getCurrentUser(token) {const decoded = jwt.verify(token, JWT_SECRET);const getUserResponse = await gqlClient.request(GetUserByEmailQuery, {email: decoded.email,});const { nextUser } = getUserResponse;if (!nextUser) {throw new Error("User not found");}delete nextUser.password;return nextUser;}
Now go to the root of this setup and start the application with node index.js
, you should be able to see that the backend app is now running, and with this our Authentication backend setup is complete!
#Setting Up The Frontend
Base Setup
To start a new React application using Create React App move to a fresh folder and type npx create-react-app frontend
. To this app, add axios
and react-router-dom
dependencies. We’ll be using tailwind css for this app but feel free to use your own CSS/UI framework. To begin with a clean slate, clean up the create-react-app boilerplate css and test files.
The strategy here to handle authentication is that when we hit the backend SignIn API, it will give us an access token, we will save that token somewhere on the client side. Then on every route visit in the frontend we will just check if the token is present on the client side and if we are able to get the user by calling the GET /me
API. We will write this logic inside a custom react hook for reusability.
First, please go through self explanatory code files and add them in your app as well
Now we will add a custom hook to check for the Authenticated user. This hook can be used inside any react component to get the Current Authenticated User.
src/lib/customHooks.js
import { useState, useEffect } from 'react';import { getAuthenticatedUser } from './common';import { APP_ROUTES } from '../utils/constants';import { useNavigate } from 'react-router-dom';export function useUser() {const [user, setUser] = useState(null);const [authenticated, setAutenticated] = useState(false);const navigate = useNavigate();useEffect(() => {async function getUserDetails() {const { authenticated, user } = await getAuthenticatedUser();if (!authenticated) {navigate(APP_ROUTES.SIGN_IN);return;}setUser(user);setAutenticated(authenticated);}getUserDetails();}, []);return { user, authenticated };}
Components
Now we can have the main app with Routes and three simple components namely Sign In
, Sign Up
and a protected Route Dashboard
that will be only accessible if the user is logged in.
src/App.js
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';import Dashboard from './components/Dashboard';import SignIn from './components/SignIn';import SignUp from './components/SignUp';import { APP_ROUTES } from './utils/constants';function App() {return (<BrowserRouter><Routes><Route exact path="/" element={<Navigate to={APP_ROUTES.DASHBOARD} />} /><Route path={APP_ROUTES.SIGN_UP} exact element={<SignUp />} /><Route path={APP_ROUTES.SIGN_IN} element={<SignIn />} /><Route path={APP_ROUTES.DASHBOARD} element={<Dashboard />} /></Routes></BrowserRouter>);}export default App;
Below are the SingUp and SignIn components - full component code with markup can be found here
components/SignUp.jsx
import React from 'react';import axios from 'axios';import { useState } from 'react';import { API_ROUTES, APP_ROUTES } from '../utils/constants';import { Link, useNavigate } from 'react-router-dom';const SignUp = () => {const navigate = useNavigate()const [email, setEmail] = useState('');const [password, setPassword] = useState('');const [firstname, setFirstname] = useState('');const [lastname, setLastname] = useState('');const [isLoading, setIsLoading] = useState(false);const signUp = async () => {try {setIsLoading(true);const response = await axios({method: 'POST',url: API_ROUTES.SIGN_UP,data: {email,password,firstname,lastname}});if (!response?.data?.token) {console.log('Something went wrong during signing up: ', response);return;}navigate(APP_ROUTES.SIGN_IN);}catch (err) {console.log('Some error occured during signing up: ', err);}finally {setIsLoading(false);}};return (// MARKUP);}export default SignUp;
components/SignIn.jsx
import React from 'react';import axios from 'axios';import { useState } from 'react';import { API_ROUTES, APP_ROUTES } from '../utils/constants';import { Link, useNavigate } from 'react-router-dom';import { useUser } from '../lib/customHooks';import { storeTokenInLocalStorage } from '../lib/common';const SignIn = () => {const navigate = useNavigate();const { user, authenticated } = useUser();if (user || authenticated) {navigate(APP_ROUTES.DASHBOARD)}const [email, setEmail] = useState('');const [password, setPassword] = useState('');const [isLoading, setIsLoading] = useState(false);const signIn = async () => {try {setIsLoading(true);const response = await axios({method: 'post',url: API_ROUTES.SIGN_IN,data: {email,password}});if (!response?.data?.token) {console.log('Something went wrong during signing in: ', response);return;}storeTokenInLocalStorage(response.data.token);navigate(APP_ROUTES.DASHBOARD)}catch (err) {console.log('Some error occured during signing in: ', err);}finally {setIsLoading(false);}};return (// MARKUP);}export default SignIn;
That’s it! Try signing up a user with the Sign Up
page, it will call our backend signup API and register the user in the database. After that, you can sign in using the above component. Once signed in, we store the token in local storage and then we redirect the user to some page that requires a user to be authenticated.
Let us build the Dashboard component which will be a protected route, we will make use of the useUser()
custom hook that we made earlier to get the authenticated user.
components/Dashboard.jsx
import React from 'react';import { useUser } from '../lib/customHooks';const Dashboard = () => {const { user, authenticated } = useUser();if (!user || !authenticated) {return <div className="p-16 bg-gray-800 h-screen"><div className="text-2xl mb-4 font-bold text-white">Dashboard</div><div className="ml-2 w-8 h-8 border-l-2 rounded-full animate-spin border-white" /></div>;}return (<div className="p-16 bg-gray-800 h-screen"><div className="text-2xl mb-4 font-bold text-white"> Dashboard </div>{user &&<div className='text-white'><div className="text-lg text-bold mb-2"> User Details </div><div className="flex"><div className="w-24 font-medium"><div> Email : </div><div> Firstname : </div><div> Lastname : </div></div><div><div> {user.email} </div><div> {user.firstname} </div><div> {user.lastname} </div></div></div></div>}</div>);}export default Dashboard;
If you delete the token from local storage and try to go to route /dashboard
you’ll be redirected to /signin
the useUser() hook handles that redirection.
Well done, a complete application right from the database all the way to a React Frontend with Authentication is ready! If you’re looking to build a robust application that scales well in production, you might want to take a look how a create react app differs from Next.js, the production framework for React!
#Conclusion
In this article, we went through the level one of building Authentication for full stack applications. We set up the database with a Hygraph schema, connected our backend app to it, created three APIs and connected three frontend components to those APIs. This was a very minimal and basic authentication setup, and something that every developer should have an idea about. This can be expanded further to support a number of use cases like fine grained authorization rules per API, expiry of tokens, security features can be further improved, and more.
However, enterprise application’s authentication & authorization requirements can become more complex over time. Managing multiple logins, revoking access, SSO Logins, OAuth flows, fine grained role based authorization rules, fingerprints, all these advanced features are required by many software products. In reality, it is quite difficult to build and manage Authentication and Authorization from scratch. OAuth flows can get tricky, you have to keep all your Auth API up-to-date with the latest standards and look out for all vulnerabilities in your system. If you build your own Auth, you have to maintain it responsibly as security issues are not something you can stall. That is why many teams who want to focus more on building their actual product and not be tangled with Authentication and Authorization workflows opt for services like AWS Cognito or Auth0.