Skip to main content

How to Add Firebase Auth to a React App 2026

·APIScout Team
Share:

Firebase Authentication integrates with the Google ecosystem and offers a generous free tier (unlimited users). This guide covers email/password auth, Google sign-in, auth state management, and route protection.

TL;DR

Firebase Auth is free for unlimited users with email/password and Google OAuth — there is no user cap on the free Spark plan, which makes it stand out against nearly every competitor. The main trade-offs compared to Clerk or Auth0: Firebase gives you raw authentication primitives without a pre-built UI. You write your own sign-up forms, handle your own error states, and wire up your own state management. That means more upfront code, but it also means more flexibility and no per-user pricing as you scale. The integration with Firestore, Cloud Storage, and Cloud Functions is seamless — if you're already in the Firebase ecosystem, adding auth is nearly zero marginal effort. If you're not already using Firebase, the setup cost is higher than reaching for Clerk's drop-in components.

What You'll Build

  • Email/password sign-up and sign-in
  • Google sign-in (one-click)
  • Auth state listener (persistent login)
  • Protected routes
  • Password reset flow

Prerequisites: React 18+, Firebase project (free tier: unlimited auth users).

1. Setup

When should you choose Firebase Auth over the alternatives? The decision comes down to your existing infrastructure and how much UI you want to build. Firebase Auth is the right call if you're already using Firestore or other Firebase services — everything speaks the same auth token natively, and you get built-in persistence, session management, and multi-provider merging without extra configuration. Clerk is worth considering if you want a pre-built, customizable UI and are willing to pay per monthly active user above the free tier — it handles the form code for you, but it's a separate vendor. Auth0 is the enterprise choice: robust, feature-rich, and priced accordingly. Supabase Auth is worth evaluating if your database is already on Supabase — it's tightly integrated with Postgres row-level security in ways Firebase can't match. For a team starting a new React app without strong pre-existing commitments, Firebase Auth's free tier and Google OAuth integration make it a strong default. See the best authentication APIs comparison for a more detailed breakdown. Also worth reading: Clerk vs NextAuth v5 for Next.js projects.

Create Firebase Project

  1. Go to Firebase Console
  2. Create a new project
  3. Go to Authentication → Sign-in method
  4. Enable: Email/Password, Google

Install

npm install firebase

Initialize Firebase

// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

2. Auth Context

React's component tree doesn't have a built-in way to share auth state across unrelated components. Without a context, every component that needs the current user would have to call getAuth() and set up its own onAuthStateChanged listener — meaning multiple redundant subscriptions firing on every auth state change, duplicate loading states, and no single source of truth. An AuthContext wraps the entire app and exposes a single user object and loading flag that every component can read via the useAuth() hook. The loading state is especially important: on initial render, Firebase checks for a persisted session asynchronously. Until that check completes, you don't know if the user is logged in or not — rendering protected content before loading is false will cause a flash of unauthenticated UI.

// contexts/AuthContext.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { User, onAuthStateChanged } from 'firebase/auth';
import { auth } from '@/lib/firebase';

interface AuthContextType {
  user: User | null;
  loading: boolean;
}

const AuthContext = createContext<AuthContextType>({
  user: null,
  loading: true,
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      setUser(user);
      setLoading(false);
    });

    return unsubscribe;
  }, []);

  return (
    <AuthContext.Provider value={{ user, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

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

3. Sign Up

Firebase uses a structured error code system rather than generic HTTP status codes. Every authentication error comes back as an object with a code property in the format auth/error-name — for example, auth/email-already-in-use, auth/weak-password, or auth/too-many-requests. The key insight is that you should always switch on err.code to produce user-friendly messages rather than surfacing Firebase's raw error strings, which are technical and inconsistent in tone. The most important codes to handle for sign-up are auth/email-already-in-use (prompt the user to sign in instead) and auth/weak-password (Firebase enforces a minimum of 6 characters by default, though you can raise this in the console).

// components/SignUpForm.tsx
'use client';
import { useState } from 'react';
import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth';
import { auth } from '@/lib/firebase';

export function SignUpForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSignUp = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      const { user } = await createUserWithEmailAndPassword(auth, email, password);
      await updateProfile(user, { displayName: name });
    } catch (err: any) {
      switch (err.code) {
        case 'auth/email-already-in-use':
          setError('Email already registered');
          break;
        case 'auth/weak-password':
          setError('Password must be at least 6 characters');
          break;
        default:
          setError('Sign up failed. Please try again.');
      }
    }
  };

  return (
    <form onSubmit={handleSignUp}>
      <input value={name} onChange={e => setName(e.target.value)} placeholder="Name" required />
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" type="email" required />
      <input value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" type="password" required />
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit">Sign Up</button>
    </form>
  );
}

4. Sign In

// components/SignInForm.tsx
'use client';
import { useState } from 'react';
import { signInWithEmailAndPassword, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
import { auth } from '@/lib/firebase';

const googleProvider = new GoogleAuthProvider();

export function SignInForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleEmailSignIn = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await signInWithEmailAndPassword(auth, email, password);
    } catch (err: any) {
      setError('Invalid email or password');
    }
  };

  const handleGoogleSignIn = async () => {
    try {
      await signInWithPopup(auth, googleProvider);
    } catch (err: any) {
      setError('Google sign-in failed');
    }
  };

  return (
    <div>
      <form onSubmit={handleEmailSignIn}>
        <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" type="email" required />
        <input value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" type="password" required />
        {error && <p className="text-red-500">{error}</p>}
        <button type="submit">Sign In</button>
      </form>

      <div className="divider">or</div>

      <button onClick={handleGoogleSignIn}>
        Sign in with Google
      </button>
    </div>
  );
}

5. Sign Out

import { signOut } from 'firebase/auth';
import { auth } from '@/lib/firebase';

export function SignOutButton() {
  return (
    <button onClick={() => signOut(auth)}>
      Sign Out
    </button>
  );
}

6. Protected Routes

Route protection in a React app requires two distinct layers, not one. The first layer is the client-side redirect: if the user isn't authenticated, push them to /login before rendering any protected UI. This is what the ProtectedRoute component below handles. But client-side protection alone is not security — it's UX. A determined user can disable JavaScript, modify local storage, or call your API directly without going through the browser UI at all. The second layer is server-side token verification on every API request that touches sensitive data. Both layers need to be in place: the client-side redirect prevents accidental exposure and provides a smooth UX, while server-side verification is the actual security boundary. Think of client-side protection as the lobby receptionist and server-side verification as the keycard reader on the server room door.

// components/ProtectedRoute.tsx
'use client';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { user, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!loading && !user) {
      router.push('/login');
    }
  }, [user, loading, router]);

  if (loading) return <div>Loading...</div>;
  if (!user) return null;

  return <>{children}</>;
}

// Usage
export default function DashboardPage() {
  return (
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  );
}

7. Password Reset

import { sendPasswordResetEmail } from 'firebase/auth';
import { auth } from '@/lib/firebase';

export function ForgotPasswordForm() {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);

  const handleReset = async (e: React.FormEvent) => {
    e.preventDefault();
    await sendPasswordResetEmail(auth, email);
    setSent(true);
  };

  if (sent) return <p>Check your email for reset instructions.</p>;

  return (
    <form onSubmit={handleReset}>
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" type="email" required />
      <button type="submit">Send Reset Email</button>
    </form>
  );
}

8. Server-Side Verification

A Firebase ID token is a short-lived JWT (1-hour expiry) that your client-side app receives after sign-in. It is cryptographically signed by Google and can be verified without a network call once you have the public keys — the Admin SDK handles key caching automatically. The reason you must verify tokens server-side is simple: the client is not a trust boundary. Any value that arrives in an HTTP request — headers, cookies, query parameters — could have been crafted by an attacker. Verifying the ID token with adminAuth.verifyIdToken() confirms that the token was issued by your specific Firebase project, has not expired, and has not been revoked. Without this step, any endpoint that accepts a uid claim from the client is exploitable. The firebase-admin package should only ever be imported in server-side code — never expose your service account credentials to the client.

// lib/firebase-admin.ts
import { initializeApp, cert } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';

const app = initializeApp({
  credential: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!)),
});

export const adminAuth = getAuth(app);
// app/api/protected/route.ts
import { adminAuth } from '@/lib/firebase-admin';

export async function GET(req: Request) {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');

  if (!token) {
    return Response.json({ error: 'Missing token' }, { status: 401 });
  }

  try {
    const decoded = await adminAuth.verifyIdToken(token);
    return Response.json({ uid: decoded.uid, email: decoded.email });
  } catch {
    return Response.json({ error: 'Invalid token' }, { status: 401 });
  }
}

Pricing

Firebase Auth is free for unlimited users. You only pay for:

FeatureFreePaid (Blaze)
Email/password auth✅ Unlimited
Social logins✅ Unlimited
Phone auth10K/month$0.01-0.06/verification
Multi-factor auth✅ (Blaze plan)
Custom domains✅ (Blaze plan)

Firebase Auth's pricing model is genuinely different from its main competitors. Clerk charges based on monthly active users above a free tier threshold — costs grow linearly with your user base. Auth0 uses a similar MAU-based model with a free tier of 7,500 MAUs, after which pricing jumps significantly. Firebase's unlimited free tier for email/password and OAuth methods means you can scale to hundreds of thousands of users without paying anything for auth. Where Firebase starts costing money is phone number verification (SMS pricing from carriers) and enterprise features like multi-factor authentication (MFA), custom JWT claims at scale, and custom auth domains — all of which require the Blaze (pay-as-you-go) plan. For most early-stage products, Firebase Auth is simply the lowest-cost option available. If you outgrow it, the migration path to services like Clerk or Auth0 is manageable since you own your user data.

Common Mistakes

MistakeImpactFix
Not using onAuthStateChangedAuth state lost on refreshAlways listen for auth state changes
Client-side only protectionAPI endpoints unprotectedVerify ID tokens server-side
Not handling error codesGeneric error messagesMap Firebase error codes to user-friendly messages
Storing Firebase config in .env without NEXT_PUBLIC_Config unavailable in browserUse NEXT_PUBLIC_ prefix for client-side config
Not enabling providers in Firebase ConsoleSign-in methods failEnable each auth method in the console

Firebase Auth in Next.js App Router

The code examples above work in Next.js pages directory and client components. If you're using the App Router, there are a few differences worth knowing. Firebase's client SDK is browser-only, so any component that uses onAuthStateChanged, signInWithPopup, or similar must be a client component (marked with 'use client'). You cannot use Firebase Auth directly in Server Components or Route Handlers without additional setup.

The pattern most teams adopt with App Router: maintain a client-side auth context (similar to the provider pattern shown above), and for protected API routes or server-side data fetching, verify the Firebase ID token on the server. Use getAuth().verifyIdToken(token) in a Route Handler or server action to authenticate requests. This gives you the same security guarantee as session cookies but uses Firebase's token infrastructure. The Firebase Admin SDK handles server-side token verification — install it separately (firebase-admin) and initialize it with a service account key stored in environment variables, not committed to your repository.

For full server-side rendering of protected pages, store the ID token in an HttpOnly cookie after login and verify it in Next.js middleware before the page renders. Firebase's session cookie API (createSessionCookie) works well with Next.js middleware for this pattern and avoids exposing the raw ID token to client-side JavaScript. One practical note: Firebase ID tokens expire after one hour. Implement token refresh logic with onIdTokenChanged rather than onAuthStateChanged if your application needs to maintain sessions longer than an hour without requiring re-login.


Deciding between Firebase and other auth providers? Our best authentication APIs guide compares free tiers, feature sets, and DX across Firebase, Clerk, Auth0, and Supabase Auth. For Next.js specifically, the Clerk vs NextAuth v5 comparison covers the trade-offs in detail. If you're evaluating OAuth-based flows, the how-to guide for OAuth2 with Auth0 is a useful companion to this article.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.