Authentication in React 18 using Firebase v9

Authentication in React 18 using Firebase v9

by Wisdom Ekpotu

To identify users of an application, it is critical to create a user authentication mechanism. On a backend server, this process might become quite complicated at times. This is when Firebase enters the picture. Firebase has made it incredibly simple to create a complete authentication infrastructure without having to worry about security, scaling, or maintenance, among other things.

In this article, we will implement an authentication flow in React 18 using Firebase v9 Web SDK. We'll enhance a starter React application to practice the following security concepts: Adding user login and logout (using email/password and with Google), Retrieving user information, using the Context API for managing the Auth state, and Protecting application routes from unauthorized users using React Router v6.

Before you begin this tutorial, you'll need the following:

  • Prior Knowledge of React.
  • Code Editor — such as VSCode.
  • A Google Account.
  • Node.js Latest LTS installed on your computer.

Ok, let's jump right in.

Create a Firebase Project

After ensuring you are logged in with your Google account, go to the Firebase Console. Click add Project and give your project a name, such as "React18-Firebasev9-Auth" in my case. Then click Continue. We won't be using Google Analytics in this case, so we'll skip that step.

You will then be redirected to the Dashboard which should look like this:

Dashboard

Go to "authentication", Click on Get Started, and navigate to Sign-in method. Here, you will see various available methods, but we will be implementing just two (Email/Password and Google) for this tutorial. Click on Email/Password, Enable and then save.

Email/Password method

Then, for Google, we repeat the process. Enable it, add the Project support email, and save it.

Google method

Authentication

Get the Starter React 18 Application

For the purpose of this tutorial, I have created a starter project using create-react-app to enable us to focus more on authentication. The starter application uses React Bootstrap to manage styling and layout. To get started, clone the react18-firebasev9-auth repository on the starter branch:

git clone -b starter https://github.com/wisdomekpotu/react18-firebasev9-auth.git

Once you clone the repo, make react18-firebasev9-auth your current directory:

cd react18-firebasev9-auth

Install the React project dependencies:

yarn install

Start the application:

yarn start

Adding Firebase to our React app

To add Firebase to our React application, we must first register it on the Firebase Console. Click on the 3rd icon, which is for Web Applications.

Adding Firebase to our app

Then add the application name and click Register app.

Registering the app

We don't need to set up Firebase hosting, so we'll skip it. You'll see your Firebase project credentials, which we'll need to set up Firebase.

Secrets for our app

The next step is to add Firebase to our project. To accomplish this, run:

yarn add firebase

Create a firebase-config.js file in the src folder. This is where we'll enter our credentials to connect to Firebase. According to the documentation for create-react-app,

  • Do not store any secrets (such as private API keys) in your React app!.
  • You must create custom environment variables beginning with REACT_APP_.

As a result, we must save all sensitive information about our Firebase project in a .env file. Go to the root of your application and create a file called .env.

touch .env

Then populate the .env file as follows with the credentials generated from Firebase :

REACT_APP_FIREBASE_API_KEY = 
REACT_APP_FIREBASE_AUTH_DOMAIN = 
REACT_APP_FIREBASE_PROJECT_ID = 
REACT_APP_FIREBASE_STORAGE_BUCKET = 
REACT_APP_FIREBASE_MESSAGING_SENDER_ID = 
REACT_APP_FIREBASE_APP_ID =

Include the .env file in the .gitignore file to prevent it from being pushed to our GitHub repo.

# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local

Add the following code to the firebase-config file:

import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export default app;

With our credentials, we initialize Firebase in our app. To access our configuration credentials, we use the process.env just right in front of the variable names as declared in the .env file. Then we import the getAuth method from the Firebase auth's module and export both for use in our app. Let's go on and write the Firebase functions for authentication.

Context API for Authentication

The Context API is a feature in React that will allow us to share state across the entire app (or part of it) lightly and with ease. Add the following code to the UserAuthContext.js file:

import { createContext, useContext, useEffect, useState } from "react";
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  onAuthStateChanged,
  signOut
} from "firebase/auth";
import { auth } from "../firebase";

const userAuthContext = createContext();

export function UserAuthContextProvider({ children }) {
  const [user, setUser] = useState({});

  function logIn(email, password) {
    return signInWithEmailAndPassword(auth, email, password);
  }
  function signUp(email, password) {
    return createUserWithEmailAndPassword(auth, email, password);
  }
  function logOut() {
    return signOut(auth);
  }

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (currentuser) => {
      console.log("Auth", currentuser);
      setUser(currentuser);
    });

    return () => {
      unsubscribe();
    };
  }, []);

  return (
    <userAuthContext.Provider
      value={{ user, logIn, signUp, logOut }}
    >
      {children}
    </userAuthContext.Provider>
  );
}

export function useUserAuth() {
  return useContext(userAuthContext);
}

We first build a context provider called UserAuthContextProvider and then pass in the children. Then we import a couple of Firebase methods (createUserWithEmailAndPassword , signInWithEmailAndPassword , and signOut) from our Firebase auth module. These methods are used to develop functions for creating, logging in, and signing out users through email and password. We also import a method named onAuthStateChanged, which monitors authentication status changes and returns the current user. We construct a function called useUserAuth and wrap it in a custom hook called useContext to make this Context API available to our app.

Signup Component Firebase Integration

We've built the Firebase functions, but they're not yet available in our app. As a result, we must import them into our App.js file. Add this line of code to the App.js file.

import { UserAuthContextProvider } from "./context/UserAuthContext";

Then wrap the routes with the provider. It should look like this.

 <UserAuthContextProvider>
      <Routes>
              <Route path="/home" element={<Home />} />
              <Route path="/" element={<Login />} />
              <Route path="/signup" element={<Signup />} />
     </Routes>
</UserAuthContextProvider>

Navigate to the Signup.js file and modify the code like this:

import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Form, Alert } from "react-bootstrap";
import { Button } from "react-bootstrap";
import { useUserAuth } from "../context/UserAuthContext";

const Signup = () => {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [password, setPassword] = useState("");
  const { signUp } = useUserAuth();
  let navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");
    try {
      await signUp(email, password);
      navigate("/");
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <>
      <div className="p-4 box">
        <h2 className="mb-3">Firebase Auth Signup</h2>
        {error && <Alert variant="danger">{error}</Alert>}
        <Form onSubmit={handleSubmit}>
          <Form.Group className="mb-3" controlId="formBasicEmail">
            <Form.Control
              type="email"
              placeholder="Email address"
              onChange={(e) => setEmail(e.target.value)}
            />
          </Form.Group>

          <Form.Group className="mb-3" controlId="formBasicPassword">
            <Form.Control
              type="password"
              placeholder="Password"
              onChange={(e) => setPassword(e.target.value)}
            />
          </Form.Group>

          <div className="d-grid gap-2">
            <Button variant="primary" type="Submit">
              Sign up
            </Button>
          </div>
        </Form>
      </div>
      <div className="p-4 box mt-3 text-center">
        Already have an account? <Link to="/">Log In</Link>
      </div>
    </>
  );
};

export default Signup;

Basically, we import the useUserAuth method from useUserContext, and then we add onChange handlers to listen to changes in our input fields and get the values. We initialize the email and password states as empty and then assign these values as the new state values. We constructed a handleSubmit function that will use our useUserAuth method to call the signUp Firebase function and then sign in users only when the submit button is clicked, after which we will redirect to the Login component page using the useNavigate hook.

Open Source Session Replay

OpenReplay is an open-source alternative to FullStory and LogRocket. It gives you full observability by replaying everything your users do on your app and showing how your stack behaves for every issue. OpenReplay is self-hosted for full control over your data.

replayer.png

Happy debugging for modern frontend teams - start monitoring your web app for free.

Login Component Firebase Integration

Open src/components/Login.js and update it as follows:

import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Form, Alert } from "react-bootstrap";
import { Button } from "react-bootstrap";
import GoogleButton from "react-google-button";
import { useUserAuth } from "../context/UserAuthContext";

const Login = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const { logIn } = useUserAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");
    try {
      await logIn(email, password);
      navigate("/home");
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <>
      <div className="p-4 box">
        <h2 className="mb-3">Firebase Auth Login</h2>
        {error && <Alert variant="danger">{error}</Alert>}
        <Form onSubmit={handleSubmit}>
          <Form.Group className="mb-3" controlId="formBasicEmail">
            <Form.Control
              type="email"
              placeholder="Email address"
              onChange={(e) => setEmail(e.target.value)}
            />
          </Form.Group>

          <Form.Group className="mb-3" controlId="formBasicPassword">
            <Form.Control
              type="password"
              placeholder="Password"
              onChange={(e) => setPassword(e.target.value)}
            />
          </Form.Group>

          <div className="d-grid gap-2">
            <Button variant="primary" type="Submit">
              Log In
            </Button>
          </div>
        </Form>
        <hr />
        <div>
          <GoogleButton
            className="g-btn"
            type="dark"
          />
        </div>
      </div>
      <div className="p-4 box mt-3 text-center">
        Don't have an account? <Link to="/signup">Sign up</Link>
      </div>
    </>
  );
};

export default Login;

The code above is similar to what we did in Signup.js. We import the useUserAuth method from UserAuthContext, and then we set email and password states to empty. Add onChange listeners to get the values from the inputs. Then we wrote a function that will call the logIn function passed down from useUserAuth to authenticate the user, and then we also make use of the useNavigate hook from "react-router-dom" to redirect to the home page.

Adding Firebase Logout Functionality

This is pretty simple to implement as all we need to do is call the logOut function. First, we go to the Home.js component, where we would find out logout button. We add an Onclick handler and then write a handleLogout function which calls the firebase logOut function gotten from the useUserContext imported. The code should look like this:

import { useNavigate } from "react-router";
import { useUserAuth } from "../context/UserAuthContext";

const Home = () => {
  const { logOut, user } = useUserAuth();
  const navigate = useNavigate();
  const handleLogout = async () => {
    try {
      await logOut();
      navigate("/");
    } catch (error) {
      console.log(error.message);
    }
  };

  return (
    <>
      <div className="p-4 box mt-3 text-center">
        Hello Welcome <br />
      </div>
      <div className="d-grid gap-2">
        <Button variant="primary" onClick={handleLogout}>
          Log out
        </Button>
      </div>
    </>
  );
};

export default Home;

Sign In With Google Auth

Navigate to the Login component. On the GoogleButton add an onClick handler onClick={handleGoogleSignIn}. Then create an asynchronous function handleGoogleSignIn, which will call the googleSignIn function from our context, authenticate the user, and redirect to the home page.

const handleGoogleSignIn = async (e) => {
  e.preventDefault();
  try {
    await googleSignIn();
    navigate("/home");
  } catch (error) {
    console.log(error.message);
  }
};

Once you have successfully authenticated users using both email/password and Google. Go back to your Firebase Console and notice that the user data has been saved.

User data was saved

Firebase Protected Route Setup

React Router v6 enables us to protect unauthorized/unauthenticated users from accessing sensitive routes. Since we do not want users having access to the home page when not logged in, so we need to make it protected. To start, create a ProtectedRoute.js file under the src/components directory:

touch src/components/ProtectedRoute.js

Populate src/auth/protected-route.js as follows:

import React from "react";
import { Navigate } from "react-router-dom";
import { useUserAuth } from "../context/UserAuthContext";
const ProtectedRoute = ({ children }) => {
  const { user } = useUserAuth();

  console.log("Check user in Private: ", user);
  if (!user) {
    return <Navigate to="/" />;
  }
  return children;
};

export default ProtectedRoute;

We import the useUserAuth method from our Context API and the useNavigate hook from React Router in the code above. If the user is not authenticated, we redirect to the login page; otherwise, we make the home page available and redirect the user to it.

After that, we must export it to use it in our App.js file. Go to App.js and make the necessary changes.

import { Container, Row, Col } from "react-bootstrap";
import { Routes, Route } from "react-router-dom";
import "./App.css";
import Home from "./components/Home";
import Login from "./components/Login";
import Signup from "./components/Signup";
import ProtectedRoute from "./components/ProtectedRoute";
import { UserAuthContextProvider } from "./context/UserAuthContext";

function App() {
  return (
    <Container style={{ width: "400px" }}>
      <Row>
        <Col>
          <UserAuthContextProvider>
            <Routes>
              <Route
                path="/home"
                element={
                  <ProtectedRoute>
                    <Home />
                  </ProtectedRoute>
                }
              />
              <Route path="/" element={<Login />} />
              <Route path="/signup" element={<Signup />} />
            </Routes>
          </UserAuthContextProvider>
        </Col>
      </Row>
    </Container>
  );
}

export default App;

In the code above, we import the ProtectRoute and proceed to wrap the Home component with it: now our home route is protected, but the login and sign up routes are still available to everybody.

The code for this tutorial can be found here GitHub Repo.

Conclusion

So far in this tutorial, we have used Firebase to authenticate users using their email/password and with a Google account. You can take this a step further by adding the "forgot password" functionality. You can also make use of Firestore to save users. And then you can also see how to implement sign-in with Twitter, Facebook, etc. -- plenty of options!

Some resources you may want to look at:

newsletter