How to Set Up Firebase Auth Securely
Complete authentication with security rules and custom claims
TL;DR
TL;DR (20 minutes): Initialize Firebase Auth, enable only needed providers, always use request.auth in security rules, verify ID tokens server-side with Admin SDK for sensitive operations, use custom claims for roles (not client-set data), and never trust client-side auth state for critical decisions.
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
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.
Configure authentication providers
In Firebase Console > Authentication > Sign-in method:
- Enable Email/Password (check "Email link" for passwordless option)
- Enable Google (configure OAuth consent screen first)
- 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.
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
}
}
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 };
}
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
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 });
}
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.
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 use
request.authto verify ownership - No rules allow unrestricted read/write (
allow read, write: if true) - ID tokens are verified server-side for sensitive operations
- Custom claims are set only via Admin SDK, never client-side
- Email verification is required for important actions
- Error messages don't reveal whether emails exist
- Only necessary auth providers are enabled
- Authorized domains are properly configured
How to Verify It Worked
- Test sign-up: Create account, verify email is sent, profile is created
- Test rules: Use Firebase Console Rules Playground to simulate requests
- Test unauthorized access: Try to read another user's data - should fail
- Test token verification: Call API with expired/invalid token - should return 401
- Test custom claims: Set admin claim, verify it appears in token and rules work
- Run emulator tests:
firebase emulators:exec "npm test"
Common Errors & Troubleshooting
Error: "Missing or insufficient permissions"
Your Security Rules are blocking the operation. Check: 1) User is authenticated, 2) Rules allow the specific operation, 3) Document path matches the rule pattern.
Error: "auth/popup-blocked"
Browser blocked the OAuth popup. Ensure signInWithPopup is called directly from a user interaction (click handler), not from async code.
Error: "auth/network-request-failed"
Network issue or Firebase services blocked. Check firewall settings, or try signInWithRedirect instead of signInWithPopup.
Custom claims not appearing
The client needs to force a token refresh with getIdToken(true) after claims are set. Claims only update when the token is refreshed.
Admin SDK "Permission denied"
Check service account credentials. Ensure the private key is properly formatted (replace \\n with actual newlines) and the service account has required IAM roles.
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