How to Set Up Firebase Auth Securely

How-To Guide

How to Set Up Firebase Auth Securely

Complete authentication with security rules and custom claims

TL;DR

Firebase Auth verifies who a user is. It doesn't decide what they can do. That part is your Firestore Security Rules. Set up the SDK, enable only the providers you actually need, and write rules that check request.auth before granting access. For any sensitive backend operation, verify the ID token server-side with the Admin SDK. Roles go in custom claims, not in data the client can write.

Prerequisites

  • A Firebase project (create one at console.firebase.google.com)
  • Node.js and npm installed
  • Firebase CLI installed (npm install -g firebase-tools)
  • Basic understanding of React or your framework of choice

Critical Security Note

Firebase Auth only verifies identity - it doesn't restrict data access. You MUST use Security Rules to control what authenticated users can access. Without proper rules, any authenticated user can read/write all your data.

Step-by-Step Guide

1

Install and initialize Firebase

npm install firebase

Create a Firebase config file (lib/firebase.ts):

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

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);
export const db = getFirestore(app);

// Use emulators in development
if (process.env.NODE_ENV === 'development') {
  connectAuthEmulator(auth, 'http://localhost:9099');
  connectFirestoreEmulator(db, 'localhost', 8080);
}

Tip: The Firebase API key is safe to expose - it only identifies your project. Security comes from Security Rules, not hiding this key.

2

Configure authentication providers

In Firebase Console > Authentication > Sign-in method:

  1. Enable Email/Password (check "Email link" for passwordless option)
  2. Enable Google (configure OAuth consent screen first)
  3. Add Authorized domains for production
# Required authorized domains:
localhost (for development)
your-app.com
your-app.vercel.app
your-app.firebaseapp.com

Disable unused providers: Only enable auth methods you actually use. Each enabled provider is a potential attack surface.

3

Implement secure authentication flows

import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signInWithPopup,
  GoogleAuthProvider,
  signOut,
  sendEmailVerification,
  sendPasswordResetEmail
} from 'firebase/auth';
import { auth } from '@/lib/firebase';

// Sign up with email/password
async function signUp(email: string, password: string) {
  try {
    const userCredential = await createUserWithEmailAndPassword(
      auth,
      email,
      password
    );

    // Send verification email
    await sendEmailVerification(userCredential.user);

    return userCredential.user;
  } catch (error: any) {
    // Handle specific errors without revealing too much
    if (error.code === 'auth/email-already-in-use') {
      throw new Error('An account with this email already exists');
    }
    if (error.code === 'auth/weak-password') {
      throw new Error('Password must be at least 6 characters');
    }
    throw new Error('Failed to create account');
  }
}

// Sign in with email/password
async function signIn(email: string, password: string) {
  try {
    const userCredential = await signInWithEmailAndPassword(
      auth,
      email,
      password
    );
    return userCredential.user;
  } catch (error: any) {
    // Don't reveal whether email exists
    throw new Error('Invalid email or password');
  }
}

// Sign in with Google
async function signInWithGoogle() {
  const provider = new GoogleAuthProvider();
  provider.setCustomParameters({
    prompt: 'select_account'  // Always show account picker
  });

  try {
    const result = await signInWithPopup(auth, provider);
    return result.user;
  } catch (error: any) {
    if (error.code === 'auth/popup-closed-by-user') {
      throw new Error('Sign-in cancelled');
    }
    throw new Error('Failed to sign in with Google');
  }
}

// Sign out
async function logout() {
  await signOut(auth);
}

// Password reset
async function resetPassword(email: string) {
  try {
    await sendPasswordResetEmail(auth, email);
    // Always show success to prevent email enumeration
  } catch (error) {
    // Silently fail - don't reveal if email exists
  }
}
4

Handle auth state properly

import { useEffect, useState } from 'react';
import { User, onAuthStateChanged, onIdTokenChanged } from 'firebase/auth';
import { auth } from '@/lib/firebase';

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

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

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

  return { user, loading };
}

// For token-based auth state (catches token refresh)
export function useAuthWithToken() {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = onIdTokenChanged(auth, async (user) => {
      if (user) {
        const token = await user.getIdToken();
        setToken(token);
        setUser(user);
      } else {
        setToken(null);
        setUser(null);
      }
      setLoading(false);
    });

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

  return { user, token, loading };
}
5

Integrate auth with Firestore Security Rules

Create firestore.rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Helper function to check authentication
    function isAuthenticated() {
      return request.auth != null;
    }

    // Helper function to check if user owns document
    function isOwner(userId) {
      return isAuthenticated() && request.auth.uid == userId;
    }

    // Helper function to check email verification
    function isEmailVerified() {
      return isAuthenticated() && request.auth.token.email_verified == true;
    }

    // User profiles - users can only access their own
    match /users/{userId} {
      allow read: if isOwner(userId);
      allow create: if isOwner(userId)
                    && request.resource.data.keys().hasOnly(['email', 'displayName', 'createdAt'])
                    && request.resource.data.email == request.auth.token.email;
      allow update: if isOwner(userId)
                    && !request.resource.data.diff(resource.data).affectedKeys()
                        .hasAny(['email', 'createdAt', 'role']);
      allow delete: if isOwner(userId);
    }

    // User's private documents
    match /users/{userId}/documents/{docId} {
      allow read, write: if isOwner(userId);
    }

    // Public posts - anyone can read, only verified authors can write
    match /posts/{postId} {
      allow read: if resource.data.status == 'published' || isOwner(resource.data.authorId);
      allow create: if isEmailVerified()
                    && request.resource.data.authorId == request.auth.uid;
      allow update: if isOwner(resource.data.authorId);
      allow delete: if isOwner(resource.data.authorId);
    }
  }
}

Deploy rules:

firebase deploy --only firestore:rules
6

Verify ID tokens server-side

For API routes or serverless functions, always verify tokens:

// Install Admin SDK
// npm install firebase-admin

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

// Initialize Admin SDK (do once)
if (!getApps().length) {
  initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
    })
  });
}

const adminAuth = getAuth();

// Middleware to verify ID token
async function verifyAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const idToken = authHeader.split('Bearer ')[1];

  try {
    // Verify the ID token
    const decodedToken = await adminAuth.verifyIdToken(idToken, true);

    // Check if token was revoked
    // The second param `true` already checks this, but be explicit
    req.user = decodedToken;
    next();
  } catch (error: any) {
    if (error.code === 'auth/id-token-expired') {
      return res.status(401).json({ error: 'Token expired' });
    }
    if (error.code === 'auth/id-token-revoked') {
      return res.status(401).json({ error: 'Token revoked' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Example API route
export default async function handler(req, res) {
  await verifyAuth(req, res, () => {});
  if (res.headersSent) return;

  // User is authenticated
  const userId = req.user.uid;

  // Fetch user-specific data...
  res.json({ userId, email: req.user.email });
}
7

Implement custom claims for roles

// Server-side: Set custom claims (Admin SDK only)
async function setUserRole(uid: string, role: 'admin' | 'moderator' | 'user') {
  await adminAuth.setCustomUserClaims(uid, { role });

  // Optionally revoke existing tokens to force refresh
  await adminAuth.revokeRefreshTokens(uid);
}

// Example: Promote user to admin
await setUserRole('user-uid-here', 'admin');

// Client-side: Force token refresh to get new claims
async function refreshToken() {
  const user = auth.currentUser;
  if (user) {
    await user.getIdToken(true);  // Force refresh
  }
}

// Client-side: Check claims
async function getUserRole(): Promise<string | null> {
  const user = auth.currentUser;
  if (!user) return null;

  const tokenResult = await user.getIdTokenResult();
  return tokenResult.claims.role as string || 'user';
}

// Security Rules: Use custom claims
// firestore.rules
match /admin/{document=**} {
  allow read, write: if request.auth != null
                     && request.auth.token.role == 'admin';
}

match /moderation/{document=**} {
  allow read, write: if request.auth != null
                     && request.auth.token.role in ['admin', 'moderator'];
}

Never trust client-set data for roles. Custom claims can only be set by the Admin SDK on the server. If you store roles in Firestore, always verify them in Security Rules, never in client code.

8

Create user profile on signup

import { doc, setDoc, serverTimestamp } from 'firebase/firestore';
import { db } from '@/lib/firebase';

// Option 1: Create profile after signup (client-side)
async function createUserProfile(user: User) {
  const userRef = doc(db, 'users', user.uid);

  await setDoc(userRef, {
    email: user.email,
    displayName: user.displayName || '',
    photoURL: user.photoURL || '',
    createdAt: serverTimestamp()
  });
}

// Call after successful signup
const user = await signUp(email, password);
await createUserProfile(user);

// Option 2: Use Cloud Function (recommended for consistency)
// functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

admin.initializeApp();

export const onUserCreated = functions.auth.user().onCreate(async (user) => {
  const userRef = admin.firestore().doc(`users/${user.uid}`);

  await userRef.set({
    email: user.email,
    displayName: user.displayName || '',
    photoURL: user.photoURL || '',
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
    role: 'user'  // Default role
  });
});

Security Checklist

  • Security Rules check request.auth before granting access
  • No rule uses allow read, write: if true anywhere
  • Sensitive API operations verify the ID token server-side with Admin SDK
  • Roles live in custom claims, set only by the Admin SDK
  • Email verification is required before any write that matters
  • Sign-in errors don't reveal whether an email address exists in the system
  • You've disabled every auth provider you're not actively using
  • Authorized domains list only your production and staging URLs

How to Verify It Worked

  1. Test sign-up: Create an account and confirm the verification email arrives. Check that the user profile document exists in Firestore.
  2. Test rules: Open Firebase Console, go to Firestore Rules Playground, and simulate a read as an unauthenticated user. It should be denied.
  3. Test cross-user access: While logged in as user A, try fetching user B's document. That should be denied. If it isn't, your ownership check is broken.
  4. Test token verification: Call your API with an expired token and confirm you get a 401.
  5. Test custom claims: Assign an admin claim, call getIdToken(true) to force a refresh, then confirm the claim appears in getIdTokenResult() and your admin rules respond correctly.
  6. Run emulator tests: firebase emulators:exec "npm test"

Common Errors & Troubleshooting

Error: "Missing or insufficient permissions"

Your Security Rules are blocking the operation. It's usually one of three things: the user isn't authenticated, the rule doesn't cover that specific operation, or the document path doesn't match the pattern you wrote.

Error: "auth/popup-blocked"

The browser blocked the OAuth popup because signInWithPopup wasn't called directly from a click handler. It can't be in a setTimeout, a promise chain, or async code that doesn't trace back to a user interaction.

Error: "auth/network-request-failed"

Network issue or Firebase services are blocked. Try signInWithRedirect instead of signInWithPopup, or check whether a firewall or ad blocker is interfering.

Custom claims not appearing

After you set claims with the Admin SDK, the existing token doesn't change. You need to call getIdToken(true) on the client to force a refresh. The new claims only arrive in the next token.

Admin SDK "Permission denied"

Double-check the service account credentials. The most common cause is a malformed private key: the string needs actual newline characters, not the literal \n you get when copying from some dashboards. Use .replace(/\\n/g, '\n') when reading it from an environment variable.

Should I use Firestore or Realtime Database with Auth?

Firestore is recommended for most apps - it has better querying, offline support, and more expressive Security Rules. Use Realtime Database only if you need real-time sync for simple data structures or cheaper pricing for high-volume reads.

How do I handle session persistence?

Firebase Auth handles this automatically. By default, sessions persist in indexedDB (web) or secure storage (mobile). You can change this with setPersistence() - use browserSessionPersistence for stricter security on shared devices.

When should I verify tokens server-side vs trust client auth state?

Always verify server-side for: database writes, payment processing, admin actions, or any sensitive operation. Client auth state is fine for: showing/hiding UI elements, displaying user info, non-sensitive reads protected by Security Rules.

How do I migrate from anonymous auth to permanent accounts?

Use linkWithCredential() to link an anonymous account to email/password or OAuth. This preserves the user's UID and all associated data. Always prompt users to convert before they leave.

Related guides:Firebase Security Rules · Firebase Auth Rules · Protect Routes & API Endpoints · Firebase Security Guide

How-To Guides

How to Set Up Firebase Auth Securely