DEV Community

Aaradhya Pathak
Aaradhya Pathak

Posted on

Clean Component Architecture: Individual components consume auth methods via the useAuth() custom hook without props passing.

How to Implement Secure Client-Side Authentication in React with Firebase and Context API

Managing authentication state across multiple React components can quickly lead to prop drilling, duplicated logic, and difficult-to-maintain code.

While state management libraries such as Redux are useful for large applications, many React applications can implement authentication cleanly using React's built-in Context API together with Firebase Authentication.

In this tutorial, we'll build a persistent authentication system using:

  • React
  • Vite
  • Firebase Authentication
  • React Context API
  • React Router

By the end, you'll have:

  • User registration
  • User login
  • User logout
  • Persistent authentication sessions
  • Protected routes
  • Centralized authentication logic

Prerequisites

Before getting started, make sure you have:

  • Node.js 18 or newer
  • Basic knowledge of React Hooks
  • Basic understanding of React Router
  • A Firebase project

You can create a Firebase project from the Firebase Console.


Step 1: Create the React Project

We'll use Vite to create a React application.

npm create vite@latest react-firebase-auth -- --template react
Enter fullscreen mode Exit fullscreen mode

Move into the project directory:

cd react-firebase-auth
Enter fullscreen mode Exit fullscreen mode

Install the required dependencies:

npm install
npm install firebase react-router-dom
Enter fullscreen mode Exit fullscreen mode

Start the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Firebase

First, install Firebase if you haven't already:

npm install firebase
Enter fullscreen mode Exit fullscreen mode

Create a .env file in the root of your project.

VITE_FIREBASE_API_KEY=your_api_key
VITE_FIREBASE_AUTH_DOMAIN=your_auth_domain
VITE_FIREBASE_PROJECT_ID=your_project_id
VITE_FIREBASE_STORAGE_BUCKET=your_storage_bucket
VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
VITE_FIREBASE_APP_ID=your_app_id
Enter fullscreen mode Exit fullscreen mode

You can find these values inside your Firebase project settings.

Important: Firebase client configuration values are not secret credentials. However, you should still configure proper Firebase Security Rules and API restrictions for production applications.


Create the Firebase Configuration

Create:

src/firebase.js
Enter fullscreen mode Exit fullscreen mode

Add the following code:

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

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);

export const auth = getAuth(app);
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Authentication Context

Instead of passing authentication data through props, we'll use React Context.

Create the following file:

src/context/AuthContext.jsx
Enter fullscreen mode Exit fullscreen mode

Add:

import {
  createContext,
  useContext,
  useEffect,
  useState,
} from "react";

import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signOut,
  onAuthStateChanged,
} from "firebase/auth";

import { auth } from "../firebase";

const AuthContext = createContext(null);

export const useAuth = () => {
  const context = useContext(AuthContext);

  if (!context) {
    throw new Error(
      "useAuth must be used within an AuthProvider"
    );
  }

  return context;
};

export const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // Register user
  const signup = (email, password) => {
    return createUserWithEmailAndPassword(
      auth,
      email,
      password
    );
  };

  // Login user
  const login = (email, password) => {
    return signInWithEmailAndPassword(
      auth,
      email,
      password
    );
  };

  // Logout user
  const logout = () => {
    return signOut(auth);
  };

  // Listen for authentication state changes
  useEffect(() => {
    const unsubscribe = onAuthStateChanged(
      auth,
      (user) => {
        setCurrentUser(user);
        setLoading(false);
      }
    );

    return unsubscribe;
  }, []);

  const value = {
    currentUser,
    signup,
    login,
    logout,
  };

  return (
    <AuthContext.Provider value={value}>
      {!loading && children}
    </AuthContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Why Use onAuthStateChanged?

Firebase provides the onAuthStateChanged observer to monitor authentication changes.

It automatically detects:

  • User login
  • User logout
  • Session restoration after page refresh
  • Authentication state changes

This means you don't need to manually store authentication tokens in localStorage.

Firebase manages the authenticated session for you.

onAuthStateChanged(auth, (user) => {
  if (user) {
    console.log("User logged in:", user.email);
  } else {
    console.log("User logged out");
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Create a Protected Route

Now let's prevent unauthenticated users from accessing private pages.

Create:

src/components/ProtectedRoute.jsx
Enter fullscreen mode Exit fullscreen mode

Add:

import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

export const ProtectedRoute = ({ children }) => {
  const { currentUser } = useAuth();

  if (!currentUser) {
    return <Navigate to="/login" replace />;
  }

  return children;
};
Enter fullscreen mode Exit fullscreen mode

Now any route wrapped inside ProtectedRoute requires authentication.


Step 5: Create the Login Page

Create:

src/pages/Login.jsx
Enter fullscreen mode Exit fullscreen mode

Add:

import { useState } from "react";
import { useNavigate } from "react-router-dom";

import { useAuth } from "../context/AuthContext";

export const Login = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const [error, setError] = useState("");
  const [isSubmitting, setIsSubmitting] =
    useState(false);

  const { login } = useAuth();

  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      setError("");
      setIsSubmitting(true);

      await login(email, password);

      navigate("/dashboard");
    } catch (error) {
      console.error(error);

      setError(
        "Failed to sign in. Please check your email and password."
      );
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div
      style={{
        maxWidth: "400px",
        margin: "50px auto",
      }}
    >
      <h2>Sign In</h2>

      {error && (
        <p style={{ color: "red" }}>
          {error}
        </p>
      )}

      <form onSubmit={handleSubmit}>

        <div>
          <label>Email Address</label>

          <input
            type="email"
            required
            value={email}
            onChange={(e) =>
              setEmail(e.target.value)
            }
            style={{
              width: "100%",
              margin: "8px 0",
            }}
          />
        </div>

        <div>
          <label>Password</label>

          <input
            type="password"
            required
            value={password}
            onChange={(e) =>
              setPassword(e.target.value)
            }
            style={{
              width: "100%",
              margin: "8px 0",
            }}
          />
        </div>

        <button
          type="submit"
          disabled={isSubmitting}
          style={{
            marginTop: "10px",
          }}
        >
          {isSubmitting
            ? "Signing in..."
            : "Log In"}
        </button>

      </form>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Step 6: Create the Dashboard

Create:

src/pages/Dashboard.jsx
Enter fullscreen mode Exit fullscreen mode

Add:

import { useAuth } from "../context/AuthContext";

export const Dashboard = () => {
  const {
    currentUser,
    logout,
  } = useAuth();

  const handleLogout = async () => {
    try {
      await logout();
    } catch (error) {
      console.error(
        "Logout failed:",
        error
      );
    }
  };

  return (
    <div style={{ padding: "20px" }}>
      <h1>
        Welcome, {currentUser?.email}
      </h1>

      <button onClick={handleLogout}>
        Sign Out
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Step 7: Configure the Application Routes

Now let's connect everything.

Update:

src/App.jsx
Enter fullscreen mode Exit fullscreen mode
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";

import {
  AuthProvider,
} from "./context/AuthContext";

import {
  ProtectedRoute,
} from "./components/ProtectedRoute";

import {
  Login,
} from "./pages/Login";

import {
  Dashboard,
} from "./pages/Dashboard";

export default function App() {
  return (
    <BrowserRouter>

      <AuthProvider>

        <Routes>

          <Route
            path="/login"
            element={<Login />}
          />

          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <Dashboard />
              </ProtectedRoute>
            }
          />

          <Route
            path="*"
            element={
              <Navigate
                to="/dashboard"
                replace
              />
            }
          />

        </Routes>

      </AuthProvider>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Enable Firebase Authentication

Open your Firebase project.

Navigate to:

Firebase Console
→ Authentication
→ Sign-in method
Enter fullscreen mode Exit fullscreen mode

Enable:

Email/Password
Enter fullscreen mode Exit fullscreen mode

Now your application is ready to authenticate users.


Project Structure

Your final project structure should look like this:

src
│
├── components
│   └── ProtectedRoute.jsx
│
├── context
│   └── AuthContext.jsx
│
├── pages
│   ├── Login.jsx
│   └── Dashboard.jsx
│
├── firebase.js
│
├── App.jsx
│
└── main.jsx
Enter fullscreen mode Exit fullscreen mode

How the Authentication Flow Works

The complete authentication workflow looks like this:

User Opens Application
        ↓
Firebase Checks Existing Session
        ↓
onAuthStateChanged Fires
        ↓
User Logged In?
   ↙             ↘
 YES             NO
  ↓               ↓
Dashboard      Login Page
  ↓
Protected Routes
Enter fullscreen mode Exit fullscreen mode

Important Security Notes

While Firebase Authentication handles many authentication concerns, production applications should still follow security best practices.

1. Do Not Store Passwords Yourself

Never store user passwords in:

  • localStorage
  • sessionStorage
  • Cookies created manually
  • Your frontend database

Always use Firebase Authentication or another secure identity provider.


2. Do Not Trust the Frontend for Authorization

Protected routes only protect the user interface.

They do not secure your backend API by themselves.

For sensitive backend APIs, verify the Firebase ID token on the server.

Example:

const token = await auth.currentUser.getIdToken();
Enter fullscreen mode Exit fullscreen mode

Send the token to your backend:

fetch("/api/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

Your backend should verify the token before returning sensitive data.


3. Configure Firebase Security Rules

If you're using:

  • Firestore
  • Firebase Storage
  • Realtime Database

Make sure you configure proper security rules.

For example, Firestore rules can restrict users so they only access their own data.


Benefits of This Architecture

Using React Context with Firebase Authentication gives you several advantages.

No Prop Drilling

Authentication state can be accessed anywhere:

const { currentUser } = useAuth();
Enter fullscreen mode Exit fullscreen mode

Persistent Sessions

Firebase automatically restores the user's authentication state after a page refresh.


Centralized Authentication Logic

Login, signup, logout, and session tracking are all managed inside one place:

AuthContext
Enter fullscreen mode Exit fullscreen mode

Protected Routes

Private pages can be secured with:

<ProtectedRoute>
  <Dashboard />
</ProtectedRoute>
Enter fullscreen mode Exit fullscreen mode

Conclusion

React Context API and Firebase Authentication make a powerful combination for small and medium-sized React applications.

With this architecture, you get:

  • Centralized authentication state
  • Persistent user sessions
  • Protected routes
  • Clean component architecture
  • No unnecessary prop drilling
  • Firebase-managed authentication sessions

The most important pattern in this setup is:

onAuthStateChanged(auth, (user) => {
  setCurrentUser(user);
});
Enter fullscreen mode Exit fullscreen mode

This observer keeps your React application synchronized with Firebase Authentication and automatically handles session restoration.

For larger applications, you can extend this architecture with:

  • Google authentication
  • GitHub authentication
  • Password reset
  • Email verification
  • Role-based authorization
  • Backend Firebase token verification
  • Firestore user profiles

With this foundation in place, you now have a clean and scalable authentication architecture ready for your next React application.


Suggested DEV.to Tags

#react #firebase #javascript #webdev
Enter fullscreen mode Exit fullscreen mode

Happy coding! 🚀

Top comments (0)