Scientyfic World

Firebase Authentication With React: Email, Google and Protected Routes

Firebase Authentication handles sign-up, sign-in, password resets, email verification and social logins, so a React app can have real accounts without you running an auth server. This guide builds a...

Share:

Get an AI summary of this article

firebase authentication blog banner

Firebase Authentication handles sign-up, sign-in, password resets, email verification and social logins, so a React app can have real accounts without you running an auth server. This guide builds a small React app with email/password and Google sign-in, a protected profile page, password reset and email verification, using the current modular Firebase SDK and React Router.

What I tested: the app below was built with Vite 8, React 19, React Router 8.4 and Firebase JS SDK 13.0. It type-checks and produces a production build. The sign-up, sign-in, error handling, password reset and verification-email flows passed four tests against the local Firebase Auth emulator (firebase-tools 15.34). Google sign-in uses a browser popup, so it was not part of those Node tests.

The 2023 version of this post used Create React App, which the React team deprecated in February 2025, and passed a history prop to React Router that current versions do not accept. It also opened with an unrelated section about Firestore. This rewrite fixes all three.

Step 1: Set Up Firebase

  1. In the Firebase console, create a project (or open an existing one).
  2. Go to Authentication, click Get started, and enable the Email/Password and Google sign-in providers.
  3. Under Authentication > Settings > Authorized domains, check that localhost is listed and add your production domain later.
  4. In Project settings > Your apps, add a Web app and copy its config values.

The web config, including the apiKey, is not a secret: it identifies your project to Firebase and is meant to ship in client code. What protects your data is Firebase Security Rules and your authorized domains, not hiding the config.

Step 2: Create the React App

npm create vite@latest auth-app -- --template react-ts
cd auth-app
npm install firebase react-router

Put the config in environment variables. Vite exposes variables prefixed with VITE_ to the browser. Commit this example file and keep your real values in .env.local:

# Copy to .env.local and fill in from Firebase console > Project settings > Your apps
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project
VITE_FIREBASE_APP_ID=
# Set to "true" to use the local Auth emulator
VITE_USE_AUTH_EMULATOR=false

Step 3: Initialize Firebase

One module initializes the app and exports the auth instance. It can switch to the local emulator for development and tests:

import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth } from 'firebase/auth';

const app = initializeApp({
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
});

export const auth = getAuth(app);

if (import.meta.env.VITE_USE_AUTH_EMULATOR === 'true') {
  connectAuthEmulator(auth, 'http://127.0.0.1:9099');
}

Step 4: Track the Signed-In User

onAuthStateChanged fires once Firebase has restored any saved session, and again on every sign-in or sign-out. Wrapping it in a context gives every component the current user and a loading flag, so the app does not flash the login page while the session is being restored:

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { onAuthStateChanged, type User } from 'firebase/auth';
import { auth } from '../firebase';

type AuthState = { user: User | null; loading: boolean };

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

export function AuthProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<AuthState>({ user: null, loading: true });

  // Firebase restores the session on page load and calls this whenever
  // the user signs in or out.
  useEffect(
    () => onAuthStateChanged(auth, user => setState({ user, loading: false })),
    [],
  );

  return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}

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

Step 5: Auth Actions and Friendly Errors

Keep Firebase calls in one module. Sign-up creates the account, sets the display name and sends a verification email. Google sign-in uses a popup. Firebase errors come with codes such as auth/invalid-credential, which should be turned into messages people understand:

import {
  GoogleAuthProvider,
  createUserWithEmailAndPassword,
  sendEmailVerification,
  sendPasswordResetEmail,
  signInWithEmailAndPassword,
  signInWithPopup,
  signOut,
  updateProfile,
  type Auth,
} from 'firebase/auth';

export async function signUp(auth: Auth, name: string, email: string, password: string) {
  const { user } = await createUserWithEmailAndPassword(auth, email, password);
  await updateProfile(user, { displayName: name });
  await sendEmailVerification(user);
  return user;
}

export const signIn = (auth: Auth, email: string, password: string) =>
  signInWithEmailAndPassword(auth, email, password);

export const signInWithGoogle = (auth: Auth) => signInWithPopup(auth, new GoogleAuthProvider());

export const resetPassword = (auth: Auth, email: string) => sendPasswordResetEmail(auth, email);

export const logOut = (auth: Auth) => signOut(auth);

// Turn Firebase error codes into messages users understand.
export function authErrorMessage(err: unknown): string {
  const code = (err as { code?: string })?.code ?? '';
  switch (code) {
    case 'auth/invalid-credential':
    case 'auth/wrong-password':
    case 'auth/user-not-found':
      return 'Email or password is incorrect.';
    case 'auth/email-already-in-use':
      return 'An account with this email already exists.';
    case 'auth/weak-password':
      return 'Password must be at least 6 characters.';
    case 'auth/invalid-email':
      return 'Enter a valid email address.';
    case 'auth/too-many-requests':
      return 'Too many attempts. Try again later.';
    case 'auth/popup-closed-by-user':
      return 'Sign-in was cancelled.';
    default:
      return 'Something went wrong. Please try again.';
  }
}

Why three codes for one message? Firebase projects created on or after September 15, 2023 have email enumeration protection on by default, so a wrong password and an unknown email come back as one invalid-credential error, and attackers cannot tell which emails have accounts. Older projects, and the Auth emulator in my tests, still return auth/wrong-password and auth/user-not-found. Mapping all three to the same message keeps the app consistent everywhere.

Popup or redirect?

Use signInWithPopup unless you have a reason not to. Firebase’s redirect best practices explain that signInWithRedirect breaks in browsers that block third-party storage (Chrome 115+, Firefox 109+, Safari 16.1+) unless you serve the auth handler from your own domain, for example with Firebase Hosting or a reverse proxy. Popups are the simplest option, though they can be blocked and are less smooth on mobile.

Step 6: Protect Routes

A layout route checks the user and redirects to the login page, remembering where the user was going:

import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './AuthProvider';

export function ProtectedRoute() {
  const { user, loading } = useAuth();
  const location = useLocation();

  if (loading) return <p>Loading…</p>;
  if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
  return <Outlet />;
}

The app wires it up, with a small guard that sends signed-in users away from the login and sign-up pages. For more on routing, see the React Router guide.

import { BrowserRouter, Navigate, Route, Routes } from 'react-router';
import { AuthProvider, useAuth } from './auth/AuthProvider';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { Login } from './pages/Login';
import { SignUp } from './pages/SignUp';
import { Profile } from './pages/Profile';

function GuestOnly({ children }: { children: React.ReactElement }) {
  const { user, loading } = useAuth();
  if (loading) return <p>Loading…</p>;
  return user ? <Navigate to="/" replace /> : children;
}

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route element={<ProtectedRoute />}>
            <Route path="/" element={<Profile />} />
          </Route>
          <Route path="/login" element={<GuestOnly><Login /></GuestOnly>} />
          <Route path="/signup" element={<GuestOnly><SignUp /></GuestOnly>} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

Step 7: Build the Pages

Login, Google sign-in and password reset

import { useState, type FormEvent } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { auth } from '../firebase';
import { authErrorMessage, resetPassword, signIn, signInWithGoogle } from '../auth/actions';

export function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const from = (location.state as { from?: Location } | null)?.from?.pathname ?? '/';
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState<string | null>(null);

  const run = async (action: () => Promise<unknown>) => {
    setMessage(null);
    try {
      await action();
      navigate(from, { replace: true });
    } catch (err) {
      setMessage(authErrorMessage(err));
    }
  };

  const onSubmit = (e: FormEvent) => {
    e.preventDefault();
    run(() => signIn(auth, email, password));
  };

  return (
    <main>
      <h1>Log in</h1>
      <form onSubmit={onSubmit}>
        <label>Email <input type="email" autoComplete="email" value={email} onChange={e => setEmail(e.target.value)} required /></label>
        <label>Password <input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required /></label>
        <button type="submit">Log in</button>
      </form>
      <button onClick={() => run(() => signInWithGoogle(auth))}>Continue with Google</button>
      <button
        onClick={async () => {
          if (!email) return setMessage('Enter your email first.');
          await resetPassword(auth, email).catch(() => {});
          // Same message either way, so the form does not reveal which emails have accounts.
          setMessage('If an account exists for that email, a reset link is on its way.');
        }}>
        Forgot password?
      </button>
      {message && <p role="alert">{message}</p>}
      <p>No account? <Link to="/signup">Sign up</Link></p>
    </main>
  );
}

The reset message is the same whether or not the email has an account, so the form does not reveal who is registered.

Sign-up

import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { auth } from '../firebase';
import { authErrorMessage, signUp } from '../auth/actions';

export function SignUp() {
  const navigate = useNavigate();
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);

  const onSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setError(null);
    try {
      await signUp(auth, name, email, password);
      navigate('/', { replace: true });
    } catch (err) {
      setError(authErrorMessage(err));
    }
  };

  return (
    <main>
      <h1>Create an account</h1>
      <form onSubmit={onSubmit}>
        <label>Name <input autoComplete="name" value={name} onChange={e => setName(e.target.value)} required /></label>
        <label>Email <input type="email" autoComplete="email" value={email} onChange={e => setEmail(e.target.value)} required /></label>
        <label>Password <input type="password" autoComplete="new-password" minLength={6} value={password} onChange={e => setPassword(e.target.value)} required /></label>
        <button type="submit">Sign up</button>
      </form>
      {error && <p role="alert">{error}</p>}
      <p>Already have an account? <Link to="/login">Log in</Link></p>
    </main>
  );
}

Profile with sign-out

import { auth } from '../firebase';
import { useAuth } from '../auth/AuthProvider';
import { logOut } from '../auth/actions';

export function Profile() {
  const { user } = useAuth();
  if (!user) return null;
  return (
    <main>
      <h1>Hello, {user.displayName ?? user.email}</h1>
      <p>{user.email} {user.emailVerified ? '(verified)' : '(check your inbox to verify your email)'}</p>
      <button onClick={() => logOut(auth)}>Log out</button>
    </main>
  );
}

Styling is left out to keep the code short; add Tailwind or plain CSS as you like.

Step 8: Test Against the Auth Emulator

The Firebase Local Emulator Suite runs Authentication on your machine, so you can test sign-up and reset flows without creating real accounts or sending real emails. It needs a Java runtime. Install the CLI and add a firebase.json:

npm install -D firebase-tools
{
  "emulators": {
    "auth": { "port": 9099 },
    "ui": { "enabled": false },
    "singleProjectMode": true
  }
}

These tests call the same actions.ts functions the app uses. The emulator exposes the verification and reset emails it would have sent through a REST endpoint, so the tests can check them:

// Runs the real auth actions against the local Firebase Auth emulator.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth } from 'firebase/auth';
import { authErrorMessage, logOut, resetPassword, signIn, signUp } from '../src/auth/actions';

const PROJECT = 'demo-auth-app';
const auth = getAuth(initializeApp({ apiKey: 'demo-key', projectId: PROJECT }));
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });

const oobCodes = async () =>
  (await (await fetch(`http://127.0.0.1:9099/emulator/v1/projects/${PROJECT}/oobCodes`)).json()).oobCodes;

const email = `ada${Date.now()}@example.com`;

test('sign up sets the display name and sends a verification email', async () => {
  const user = await signUp(auth, 'Ada', email, 'correct-horse');
  assert.equal(user.displayName, 'Ada');
  assert.equal(user.emailVerified, false);
  const codes = await oobCodes();
  assert.ok(codes.some((c: any) => c.email === email && c.requestType === 'VERIFY_EMAIL'));
  await logOut(auth);
  assert.equal(auth.currentUser, null);
});

test('sign in works with the right password and maps errors otherwise', async () => {
  const { user } = await signIn(auth, email, 'correct-horse');
  assert.equal(user.email, email);
  await logOut(auth);
  const err = await signIn(auth, email, 'wrong-password').catch(e => e);
  assert.equal(authErrorMessage(err), 'Email or password is incorrect.');
});

test('duplicate sign-up and weak passwords get friendly messages', async () => {
  const dup = await signUp(auth, 'Ada', email, 'correct-horse').catch(e => e);
  assert.equal(authErrorMessage(dup), 'An account with this email already exists.');
  const weak = await signUp(auth, 'Bob', `bob${Date.now()}@example.com`, '123').catch(e => e);
  assert.equal(authErrorMessage(weak), 'Password must be at least 6 characters.');
});

test('password reset sends a reset email', async () => {
  await resetPassword(auth, email);
  const codes = await oobCodes();
  assert.ok(codes.some((c: any) => c.email === email && c.requestType === 'PASSWORD_RESET'));
});
$ npx firebase emulators:exec --only auth --project demo-auth-app "npx tsx --test scripts/auth-flows.test.ts"
✔ sign up sets the display name and sends a verification email
✔ sign in works with the right password and maps errors otherwise
✔ duplicate sign-up and weak passwords get friendly messages
✔ password reset sends a reset email
ℹ pass 4

Project IDs that start with demo- tell the emulator not to touch any real Firebase project. To run the app itself against the emulator, start it with npx firebase emulators:start --only auth and set VITE_USE_AUTH_EMULATOR=true.

Going to Production

  • Add your production domain to Authorized domains.
  • Protect data with Security Rules (Firestore, Storage) that check request.auth; hiding buttons in the UI is not security.
  • For your own backend, send the user’s ID token (await user.getIdToken()) and verify it on the server with the Firebase Admin SDK.
  • Consider Firebase App Check to reduce abuse of your project from unauthorized clients.
  • Customize the verification and reset email templates and sender name in the console.

Conclusion

With the modular Firebase SDK, authentication in React comes down to a few small pieces: an initialized auth instance, an onAuthStateChanged context, a module of auth actions with friendly errors, and a protected route. Prefer popups over redirects, keep security in Security Rules and server-side token checks, and test the flows against the Auth emulator. If you only need a quick demo login with no backend at all, see login authentication without a backend, but use a real service like Firebase for anything with real users.

FAQs

Is it safe to put the Firebase config in my React app?

Yes. The web config, including the API key, identifies your project and is designed to be public. Protect your data with Security Rules, authorized domains and, optionally, App Check.

Why does a wrong password show auth/invalid-credential?

Projects created on or after September 15, 2023 have email enumeration protection enabled by default, which reports wrong passwords and unknown emails as one invalid-credential error so attackers cannot tell which emails have accounts. Older projects and the Auth emulator may still return auth/wrong-password or auth/user-not-found.

Should I use signInWithPopup or signInWithRedirect?

Use signInWithPopup in most web apps. signInWithRedirect fails in browsers that block third-party storage unless you serve Firebase’s auth handler from your own domain.

How do I keep users logged in after a refresh?

Firebase persists the session in the browser by default. Wait for the first onAuthStateChanged callback before deciding whether to show the login page, as the AuthProvider in this guide does with its loading flag.

How do I protect routes in React Router?

Use a layout route that reads the current user from context and renders an Outlet when signed in, or a Navigate to the login page when not.

Can I test Firebase Authentication without a real project?

Yes. The Firebase Auth emulator runs locally; use a project ID starting with demo- and connect the SDK with connectAuthEmulator.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts