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
Move into the project directory:
cd react-firebase-auth
Install the required dependencies:
npm install
npm install firebase react-router-dom
Start the development server:
npm run dev
Step 2: Configure Firebase
First, install Firebase if you haven't already:
npm install firebase
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
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
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);
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
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>
);
};
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");
}
});
Step 4: Create a Protected Route
Now let's prevent unauthenticated users from accessing private pages.
Create:
src/components/ProtectedRoute.jsx
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;
};
Now any route wrapped inside ProtectedRoute requires authentication.
Step 5: Create the Login Page
Create:
src/pages/Login.jsx
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>
);
};
Step 6: Create the Dashboard
Create:
src/pages/Dashboard.jsx
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>
);
};
Step 7: Configure the Application Routes
Now let's connect everything.
Update:
src/App.jsx
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>
);
}
Step 8: Enable Firebase Authentication
Open your Firebase project.
Navigate to:
Firebase Console
→ Authentication
→ Sign-in method
Enable:
Email/Password
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
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
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();
Send the token to your backend:
fetch("/api/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
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();
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
Protected Routes
Private pages can be secured with:
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
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);
});
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
Happy coding! 🚀
Top comments (0)