Easily restore your project to a previous version with our new Instant One-click Backup Recovery

How to add authentication to React applications

In this article, we’ll explore with the help of a demo, how to set up authentication in a Create React App.
Aagam Vadecha

Last updated by Aagam

Nov 04, 2024

Originally written by Aagam

How to add authentication to React applications

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:

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 passwordin the schema, it will look something like this:

UserSchema.png

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 initin a fresh folder to create a new application and install the following dependencies using npm install

We will be using bcryptjsto hash passwords, expressand corsto manage the backend API, dotenvto support environment variables via a .env file, jsonwebtokento sign, and decode the jwt-tokens for authentication, and finally graphql, graphql-requestto fetch data from Hygraph GraphQL API.

Let's begin with some base backend server setup. Please add the following files

.env

JWT_SECRET=SUPERSECRET
JWT_EXPIRES_IN=1 hour
HYGRAPH_URL=VALUE
HYGRAPH_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
email
}
}
`;
export const GetUserByEmailQuery = gql`
query getUserByEmailQuery($email: String!) {
nextUser(where: { email: $email }, stage: DRAFT) {
id
email
firstname
lastname
password
}
}
`;

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/merespectively.

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:

  1. Destructure input from the request.
  2. Validate the input.
  3. Call the service function to run the respective logic.
  4. Get response from service and send it back to the client.
  5. 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 LOGIC
async 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 LOGIC
async 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 LOGIC
async 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 axiosand 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

src/utils/constants.js

src/lib/common.js

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 Dashboardthat 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;

SignUp.png

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;

SignIn.png

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;

Dashboard.png

If you delete the token from local storage and try to go to route /dashboardyou’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.

Blog Authors

Share with others

Sign up for our newsletter!

Be the first to know about releases and industry news and insights.